Vue 3 Reactive State Management

https://unsplash.com/@parrish

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

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?

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?

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?

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

  • 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

  • Projections
  • Sinks

Dataflow In Sink

  • 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 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
  • 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

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 the Foo module, 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
  • 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 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
  • 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

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

sink({ select, getStore }) {
const someProjection = select('foo.some-projection').value;
const someStoreValue = getStore(fooStateKey).id;
}

👉 getStoreis 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
  • 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

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

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store