Vue 3 Reactive State Management

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
- 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 astore
,projections
, andsinks
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
- 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({ 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 theFoo
module, and TypeScript will understand its shape- The
id
,createdAt
,firstName
, andlastName
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 - 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 - The
event
being destructured here could beSomethingHappened
orSomethingElseHappened
, both of which have apayload
of something called abarItem
… 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 - The
store
being destructured here is for theFooModule
, earlier we defined a property namedbar
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 - Wrap the work in a
try/catch
, handle it appropriately - Use a
loading
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 ofsink
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 viasink.broadcast
- Broadcasting from a sink 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 👋