Vue 3 Reactive State Management
By: Jason Awbrey
Tags: JavaScript, State Management, Typescript, Reactive Programming, Vue

Update 6/14/21 — I am now slowly working on open sourcing this project. You can follow along (if the videos are still up) at Twitch @ jsonberry When it’s ready it’ll be in NPM as state-sink

Introduction

Sup nerds 👋

This is not an opinion piece, nor is it an introduction to front-end state management techniques or reactive programming.

There be no framework bashing here.

This is a write-up of an event-driven reactive approach to managing state in Vue 3 applications that leverages native Vue APIs.

Why not React? Why not Angular? Why not X?

Good question 🤔

On the new team that I help we chose to go with Vue. We determined it was going to be the easiest on the team to pick up, and we needed a library that was good at providing incremental value that also came with some guardrails.

When choosing Vue, general Svelte adoption and TypeScript suppport wasn’t ready yet, and no one believes me when I tell them Cycle is really where it’s at.

We work in the healthcare industry. IE11 still needs support in the space we occupy. There’s headway being made to jettison the support but we would never leave a healthcare provider in the dust. Vue 3 does not have support for IE11 yet. Even though it’s on the roadmap, it’s not likely to be implemented soon and we’ve already passed the first estimate for implementation… and oops, edit, now it looks like it never will.

I chose to introduce the Vue 2 Composition API Plugin to allow for an easier migration path from 2 👉3 and so we could retain IE11 support while using the modern reactive Vue APIs.

Yes, this means Vue 2 is being used in this writeup, but it will all look and feel like Vue 3 and how the new Reactive and Composition APIs are being used is not likely to change drastically during the migration 🤞

Why not Vuex? Why not pure Composition API? Why not X?

Good question 🤔

By choosing the migration path with the Composition API it left Vuex support questionable as I didn’t want to mix it with the new APIs via the plugin. When we first started development with the technique Vuex wasn’t able to handle the Composition API (it’s closer now).

Vuex also doesn’t meet my standards for a state management library. It’s close… but having some experience in the front-end state management space since rollin’ my own and then getting more sophisticated with using things like Baobab in AngularJS, moving on to stuff like Redux, MST, XState, NgRx.. and generally playing with them all in some capacity…

I learned I enjoy a reactive event-driven declarative approach over the imperative nature of options like Akita, MST, plain Hooks in React, Vuex, etc.

👆 No bad blood there, and some people may disagree with my characterizations, but this is the internet and you’ll never catch me!

I first started using this technique in late 2019 by implementing it in React, using a combination of Hooks, Immer, and RxJS. The rest of the post is about what it looks like adapted to Vue 3.

What’s different about your technique?

Nothing here will be drastically different than anything else you’ll find out there. If you go searching for “state management in Vue 3”, you’re basically going to find the same stuff here as you will there.. though, the approach detailed here leans heavily into declarative, reactive architecture, and guardrails.

Vuex 5 is inching towards things you’ll find here, though important parts of the API remain firmly in the imperative camp, and that’s not my jam.

Architecture of Sink

To simplify references to the technique I’ll give it a name for the purpose of this post and call it Sink.

Breakin’ It Down

Sink uses a collection of modules to organize all of the state management concerns, they are responsible for:

Modeling and storing data

Reacting to events

Propagating data changes throughout the application

Generally speaking, if you have a new concern or entity that needs tracking, create a new module.

Sink Has Three Components

Stores

Projections

Sinks

Dataflow In Sink

Data lives in stores

Read work is done through projections

Events are broadcast to the system through one shared channel

Event handlers ( sinks ) listen to one or more events and respond to them

) listen to one or more events and respond to them Non-read work is done inside of a sink

event 👉 handle it 👉 update store 👉 projections based on stores 👉 consumers

Modules

A module consists of a store, projections, and sinks. All modules are included when we instantiate Sink, and they activate when we initialize Sink. Modules help to organize concerns but also play an important role under the hood to help make everything work.

Create a module 👇

import { createModule } from 'sink';

import { fooStore as store } from './store';

import * as projections from './projections';

import * as sinks from './sinks';



export const fooModule = createModule({

store,

projections,

sinks,

});

createModule takes an Object which accepts a store , projections , and sinks

Include the module into our instance of Sink👇

import { Sink } from 'sink';

import { fooModule } from '@/foo'; export default new Sink({

modules: [

fooModule,

],

});

Initialize Sink 👇

import sink from '@/sink'; export default defineComponent({

setup() {

sink.initialize();

}

});

This is done at the highest level needed — for simplicity, you’d do this in your App.vue

Once you initialize your instance of Sink, the modules are activated

Stores

The store is how your data is modeled and where your raw state lives.

Start with an interface 👇

export interface FooState {

id: string;

createdAt: string;

firstName: string;

lastName: string;

bar: any[];

}

Create the store 👇

import { createStore } from 'sink';

import { FooState } from './foo-state.interface'; export const fooStateKey = 'foo-state'; export const fooInitialState: FooState = {

id: null;

createdAt: null;

firstName: null;

lastName: null;

bar: [];

} export const fooStore = createStore(

fooStateKey,

fooInitialState,

);

We create a key that gets associated with the store

that gets associated with the Here we also create the initial state of the store

Projections

A projection is a computed value that’s source is raw state and/or other projections. These are reactive. Changes to the source of a projection trigger updates to flow automatically to their consumers. Multiple consumers of the same projection do not trigger uneccessary computations.

Start by modeling a projection with an interface (if applicable) 👇

export interface SomeProjection {

id: number;

createdAtFormatted: string;

name: string;

}

Create a projection map 👇

import { SomeProjection } from './some-projection.interface'; export interface FooProjectionMap {

'foo.some-projection': SomeProjection;

}

A projection map enables some type checking saftey when creating projections, and also helps the shape of the projection come through when being consumed.

Create a projector 👇

import { createProjector } from 'state-sink';

import { FooState } from './foo-state.interface'; export const fooProjector = createProjector<

FooProjectionMap,

FooState,

>();

A projector is a utility that makes it easier to create projections.

Creating Projections

Now with some of the setup out of the way, you just add projections here, and they automatically become available to use throughout the application.. in components, other projections, and sinks.

import { SomeProjection } from './some-projection.interface';

import { fooProjector } from './foo-projector'; export const someProjection = fooProjector<SomeProjection>(

'foo.some-projection',

({ store }) => ({

id: store.id;

createdAtFormatted: store.createdAt.slice(0, 10),

name: `${store.firstName} ${store.lastName}`

})

)

The projector takes two arguments: a string and a function

'foo.some-projection' This string is passed as the key to the projection .. and because of how we created the projector , it’s strongly typed. It only lets you pass valid strings that were described by the projector map . You’ll see this key again later when we consume this projection

This string is passed as the to the .. and because of how we created the , it’s strongly typed. It only lets you pass valid strings that were described by the . You’ll see this again later when we consume this ({ store }) We’re destructuring the store out of the first parameter passed to the function to get access to values of the reactive raw state . This is the store for the Foo module , and TypeScript will understand its shape

We’re destructuring the out of the first parameter passed to the function to get access to values of the reactive . This is the for the , and TypeScript will understand its shape The id , createdAt , firstName , and lastName are pieces of raw state from our store. The projection tracks changes to those and automatically updates consumers with a newly computed value

Consuming Projections 😋

Projections Inside of a Vue Component

<template>

<div>

{{ someProjection.id }}

{{ someProjection.createdAtFormatted }}

{{ someProjection.name }}

</div>

</template> <script>

import sink from '@/sink'; defineComponent({

setup () {

return {

someProjection: sink.select('foo.some-projection'),

}

}

})

</script>

We’ve imported our instance of sink , and not Sink itself

, and not itself The select method is aware of all available projection keys, so as you start typing TypeScript should give you hints in your IDE for what is available. It’s up to the team to decide on a naming convention to help organize your projection keys

It’s not obvious here in the example code so I’m just going to point out…

👉 If any of the data for that projection changes, all you have to do to get this component to render the new data is already done, the change propagates automatically and Vue will take care of the rendering.

Not grounding breaking, just connecting the dots! 😺

Projections Inside of Projections

import { barProjector } from './bar-projector'; export const anotherProjection = barProjector<string>(

'bar.another-projection',

({ select }) => {

const someProjection = select('foo.some-projection').value;

return someProjection.id.toString();

}

)

All projections in the application are available to you here through select

Projections Inside of a Sink

export const someSink = createSink({

sources: [SomethingHappened],

sink({ select }) {

const someProjection = select('foo.some-projection').value;

},

});

We haven’t looked at sinks yet, but that 👆 just goes to show that projections are also available in sinks through a similar API

Sinks

Sinks are event handlers. All events are streamed through the same channel, and all sinks have access to that channel. You define which events the sink should care about by listing the events as sources. Add at least one event as a source, but you can add as many as you like. The idea for the naming conventions align loosely with sinks and Cycle’s dataflow architecture.

Creating a Sink and Manipulating a Store

The only place you can manipulate a module’s store is through it’s associated sinks. This helps create some guardrails in the architecture… need to know how a store is changing? Look at that module’s sinks.

import { createSink } from 'sink';

import { SomethingHappened, SomethingElseHappened } from '@/foo'; export const someFooSink = createSink({

sources: [SomethingHappened, SomethingElseHappened],

sink({ event, store }) {

store.bar.push(event.payload.barItem);

},

});

The sink created here is included into the FooModule shown previously, all you need to do after that setup is add sinks here and they’ll be automatically included

created here is included into the shown previously, all you need to do after that setup is add here and they’ll be automatically included The event being destructured here could be SomethingHappened or SomethingElseHappened , both of which have a payload of something called a barItem … showcasing that you can have multiple events as the source for the sink to be triggered, and that you get direct access to the event

being destructured here could be or , both of which have a of something called a … showcasing that you can have multiple as the for the to be triggered, and that you get direct access to the The store being destructured here is for the FooModule , earlier we defined a property named bar that is an Array of anything.. and here we simply push those items into the Array to affect the state of the store

Sinks with Network Requests, Managing Loading State, and Error Handling

It’s common to declare that something is loading while doing a network request and to wrap that work up with error handling. Those sorts of tasks are supposed to be straight forward with Sink, they look something like this 👇

export const getSomeStuff = createSink({

sources: [SomePageLoaded],

async sink({ store }) {

store.loading = true; try {

const { data } = await axios.get('/api/stuff');

store.data = data;

} catch (error) {

store.errorMessage = error.message;

} finally {

store.loading = false;

}

},

});

Declare the sink as an async function, and await your data

as an function, and your data Wrap the work in a try/catch , handle it appropriately

, handle it appropriately Use a loading property for that store and toggle it during the work

property for that store and toggle it during the work Projections that are looking at the store’s loading property will get automatic updates that can be used to render changes to the UI

Projections and External Stores in Sinks

As part of the API, all projections and read-only versions of other module’s stores are available in a sink:

sink({ select, getStore }) {

const someProjection = select('foo.some-projection').value;

const someStoreValue = getStore(fooStateKey).id;

}

👉 getStore is also available in all projections.

Events

Everything in Sink is tied together by broadcasting events. They are simple plain Objects with a type and an optional payload.

Create an event 👇

import { createEvent, payload } from 'sink'; export const SomethingHappend = createEvent(

'a string that is unique for all events',

payload<{ foo: 'string' }>(),

)

Broadcast an event From a Vue Component 👇

import sink from '@/sink';

import { SomethingHappened } from '@/foo'; export default defineComponent({

setup() {

sink.broadcast(SomethingHappened({ foo: 'hello world' }));

}

});

We use the broadcast method from our instance of sink to send that event through the channel that all of the sinks are tapped into

Broadcast an event from a sink 👇

import { createSink } from 'sink';

import { SomePageLoaded, SomethingHappened } from '@/foo'; export const someFooSink = createSink({

sources: [SomePageLoaded],

sink({ broadcast }) {

broadcast(SomethingHappened({ foo: 'hello world' }));

},

});

broadcast here functions exactly the same as it does via sink.broadcast

here functions exactly the same as it does via Broadcasting from a sink can be useful if you need to signal that an operation has succeeded or failed

from a can be useful if you need to signal that an operation has succeeded or failed Broadcasting an event from a sink is entirely optional

Unit Testing

As a valuable part of any project, I attempted to make unit testing sinks and projections convenient by exposing an API.

While not fancy, I think this approach lends itself really well to shipping solid features… if you can encapsulate and isolate the majority of your business logic to simple functions that can be tested it feels like a big win.

Unit Testing a Sink with Jest

import { someFooSink } from './sinks'; describe('someFooSink', () => {

describe('when it gets an event', () => {

it('should increment bar by 1', () => {

const store = { bar: 0 }; someFooSink.sink({ store }); expect(store.bar).toBe(1);

})

})

})

Unit Testing a Projection

import { someProjection } from './projections'; describe('someProjection', () => {

it('should return a full name', () => {

const store = {

firstName: 'Jason',

lastName: 'Awbrey',

};



const actual = someProjection.projection({ store }); expect(actual).toBe('Jason Awbrey');

})

})

Concluding Thoughts

The Codebase

A lot of what enables sink to function is made possible by the Vue Composition and Reactive APIs, using such pieces as:

I also use an RxJS Subject to help support event broadcasting and registering callbacks, and it makes up a very small portion of the code.

Another very helpful utility is ts-action, which helps craft the events.

The rest is just the Sink code that glues it all together, and it’s fairly minimal since we lean heavily into the native Vue APIs.

How to Install Sink

Update 6/14/21 — I am now slowly working on open sourcing this project. You can follow along (if the videos are still up) at Twitch @ jsonberry When it’s ready it’ll be in NPM as state-sink

That’s not an option yet. I haven’t set aside time to fully open source the library. If there was somehow a spike of interest in moving it into the OS world then I’d see about finding time for it 😸

For an open source version I would want to target multiple frameworks so that the API could remain consistent and I could bring it into any JavaScript framework that I work with.

We’re dogfooding it extensively on our team and that’s going well, and if we can budget resources for open source contributions then I would hopefully target introducing the library to the public.

Outside of that… I really just wanted to show that this stuff is possible mostly with just using the native Vue APIs and contribute to the conversations that are happening in the front-end space relative to reactive approaches to state management.

Blog posts are never done.

Later nerds 👋

https://twitter.com/jsonberrry