developmentState management in VislyWritten by Emil Sjölander on Mon Jul 20 2020

Visly is a large, complex React app - as you can probably imagine, state management is something we think a lot about. Recently, we decided to move away from using popular community state management frameworks and implement our own solution, named simply visly-state. To many this may sound like a bad idea, and to be honest we thought so too at first. But after attempting to solve our problems with existing solutions, nothing was working like we wanted it to, so we decided to give our own a try. We're incredibly happy with the end result. Let me tell you why we built our own solution and how it works!

Background

First, I think it’s important to give a quick introduction to the technical architecture of Visly for context.

Visly is an electron app written in React & Typescript with a background Node process that manages shared state and our code generation pipeline. The frontend itself is split up into many smaller React apps, each running within its own electron renderer instance. This allows tabs in Visly to render independently as they are separate render processes. Each render process (tab) talks to the background process to fetch project data, which in turn is read from the file system. The render processes then cache this data locally to speed up interactions. Mutations, or changes to the data, are synchronized back to the background process and then shared with all other render processes to make sure they also have the latest changes.

An important thing to note is that the background and render processes operate on the same data models, but keep local copies to stay performant. Also, even though our background process acts as a backend, it runs locally, meaning we don't have the same performance and availability concerns a typical web app may have.

The problem

Before migrating to visly-state, we were using a mix of Apollo, Zustand, React context, and unmanaged global object stores to store state in Visly. This led to a ton of problems keeping state between the various stores in sync. While this was mostly a problem of our own creation - we kept adding solutions without going back and cleaning up old solutions - and could have been solved by implementing and sticking with a single solution, the reason we kept adding additional solutions was that nothing we tried solved all our problems.

The issue with Apollo was not so much Apollo, but rather GraphQL; Visly is just not a great use case for it. In Visly, we need access to all data on every page of the application, while GraphQL is built for apps where every page only uses a subset of the available data. In addition to this, while Apollo does offer subscriptions for live updating data, they aren't designed to be the main delivery mechanism for data, or design for apps where all data is live. We actually ended up writing our own subscription mechanisms as well as disabling the built-in Apollo cache.

The issue with react context is that it's very hard to enable minimal updates to the component tree when data changes. When data changes in a context, then all subscribers to that context change, whether or not they depend on the specific data that changed.

The issue with Zustand, Redux, Recoil, and many other existing global state management solutions, which do offer fine-grained control of component re-rendering, was that they were very tightly coupled to the React ecosystem, whereas we wanted to make use of the same functionality across our frontend, backend, and command line tools.

We wanted a single solution that would solve all these problems and give us the ability to implement cross-process state synchronization, history management, performance debugging, and transaction support on a higher level, outside of product code. So we decided to build visly-state.

Requirements

As we were looking to overhaul state management within Visly, we started by taking a step back to identify all our requirements. We then tried to work these into various open source tools such as redux, mobx, and even new libraries such as Recoil. While we could have gotten them all to work, none of them felt like a natural fit with how we wanted to manage our state, and would have required significant work to build plugins and wrappers around their APIs anyways. So what were we looking for? We wanted a tool that had:

  • Support for history management (i.e. - undo/redo);
  • Minimal re-rendering of React components;
  • Support for both React and Node environments;
  • Built-in concepts for synchronizing state between different processes;
  • Ability to split state between multiple stores and perform mutations across them;
  • Plain function mutations & selectors that can easily be tested and reused outside of React;
  • Immutable data; and
  • Was built for Typescript.

We also plan to implement support for concurrent mode in future - it's something we're super excited to make use of in Visly, and I'm confident that we can implement support for in visly-state when it reaches stability.

visly-state

Time to take a look at how visly-state works and how it solves these problems for us. In its most simple usage, visly-state can be used almost exactly as React’s built-in useState, except that the state is stored in a global store instead of attached directly to a single component instance, meaning the state is shared between all components that read from it.

import { state } from 'visly-state'
import { useVislyState } from 'visly-state/hooks'

const counter = state({ count: 0 })

function Component() {
    const [state, setState] = useVislyState(counter)
    const increment = () => setState(s => { s.count += 1 })
    return <button onClick={increment}>Count: {state.count}</button>
}

In practice, this simple usage isn’t very common. We use it mostly for very simple global state, such as our zoomState, which keeps track of how much you have zoomed into the Visly canvas.

Instead, we make use of useValue and useMutation hooks, which take selector and mutation functions respectively. Selectors are functions which, given a piece of state, return some derived state, either a subset of the state or some new state computed from the underlying state object. Mutations functions are what they sound like, function that apply mutations to a state object.

import { state } from 'visly-state'
import { useValue, useMutation } from 'visly-state/hooks'

interface CountState {
    count: number
}

const counter = state({ count: 0 })

// Selector function
function getCount(state: CountState) {
    return state.count
}

// Mutation function
function incrementCount(state: CountState) {
    state.count += 1
}

function Component() {
    const count = useValue(counter, getCount)
    const increment = useMutation(counter, incrementCount)
    return <button onClick={increment}>Count: {count}</button>
}

As you can see from this example, selector and mutation functions are just plain old Javascript functions and therefore can be shared with code running outside of a React app and can be unit tested very easily.

test('count is correctly incremented', () => {
    const state = { count: 1 }
    incrementCount(state)
    expect(state.count).toBe(2)
})

Another benefit of using useValue with a selector function is that components can subscribe to only a subset of the data in a state object, meaning if you have a large state object and only change a single property, then only components that read that property will update.

import { state } from 'visly-state'
import { useValue, useMutation } from 'visly-state/hooks'

interface CountState {
    count1: number
    count2: number
}

const counter = state({ count1: 0, count2: 0 })

// Will only re-render if button in Component2 is pressed
function Component1() {
    const count = useValue(counter, s => s.count1)
    const increment = useMutation(counter, s => { s.count2 += 1 })
    return <button onClick={increment}>Count1: {count}</button>
}

// Will only re-render if button in Component1 is pressed
function Component2() {
    const count = useValue(counter, s => s.count2)
    const increment = useMutation(counter, s => { s.count1 += 1 })
    return <button onClick={increment}>Count2: {count}</button>
}

One thing you’ll notice is how mutations make changes to the state directly instead of returning a new object with the updates included. You may even think that this is a very bad pattern, we shouldn’t be mutating state, state should be immutable! Well it is :) Under the hood visly-state uses immer, a fantastic javascript library for working with immutable data. Before visly-state calls your mutations it will actually create a mutable copy of the state object for you to apply your changes to, it will then use immer to track the changes you have made and use those changes to create a new immutable state object. The benefit of this is that mutations become very simple to write compared to typical reducers which require destructuring many layers of nested properties.

Synced state

Another benefit we gain from using immer to track changes during mutations is that we can use that change set to efficiently synchronize state with a remote store (for example our background process!). We’ve built this functionality into what we called syncedState. A synced state works just like a number state object except all changes are synchronized via a global syncAdapter, this sync adapter can be implemented on both the frontend and backend to enable live sync of state updates.

import { syncedState, setSyncAdapter } from 'visly-state'
import { WSSyncAdapter } from 'visly-state/sync/socket'

setSyncAdapter(WSSyncAdapter('wss://localhost:3000'))

const counter = syncedState('CountState', { count: 1 })

That’s all you need to make sure all changed to counter are synced back to your backend. Your backend will then implement its own sync adapter, which can contain additional logic for reconciling multiple change sets and managing any potential conflicts. As you can see, the only difference from a regular local state here is that we supply what we call a syncKey, which is an identifier that identifies the state so we know where to apply an incoming change set.

Combined state

Most applications' state is made up of both local and synced state. This is because you don’t want to sync all the state: you will want to split them out into two separate state objects, local and remote. The problem with this, though, is that the two states usually have a strong dependency on one another. For example, the remote state may contain all the items in a list, but the local state contains information on what item is selected. This means you have to run selectors and mutation across both states in order to keep the up to date. For this, visly-state provides a combinedState. A combined state solves this problem by applying mutations to multiple stores within a transaction. If a mutation fails for some reason, changes on both states are rolled back, and no React components are updated until both underlying states have had their updates applied.

import { state, syncedState, combinedState } from 'visly-state'

const local = state({ selected: null })
const remote = syncedState({ items: [] })
const appState = combinedState({local, remote})

const selectedItem = useValue(appState, ({local, remote}) => {
    return remote.items[local.selected]
})

const deleteItem = useMutation(appState, ({local, remote}, index) => {
    delete remote.items[index]
    if (local.selected === index) {
        local.selected = null
    }
})

Derived state

It’s often useful to create an abstraction between your underlying state, which may be quite low-level, and the object or view models, which your React components use to render your UI. As you’ve already seen, you can use selectors to perform transformation on data, or just return a subset of data from the underlying state. However, this still requires exporting the underlying state to components. How visly-state solves this is through what we call derivedState. Derived state works a lot like useValue, taking a store and a selector, but instead of returning a value, it returns a new state object that can then be passed to useValue with yet another selector.

import { state, derivedState } from 'visly-state'

const appState = state({ 
    todos: { 
        '123': { 
            id: '123', 
            title: 'Open source' 
        } 
    } 
})

export derivedState(appState, s => {
    return { todos: Object.values(s.todos) }
})

In the above example, we store our todos in an object where the key is the id of the todo. While this is a great way to store the todo information, our React components would much rather access todos as an array instead of an object. So instead of exporting appState, we instead create a derived state and export that instead. Because derived state can perform arbitrary changes to the structure of the underlying state, even omitting information, we don’t allow calling mutations on derived state. However everything else works as expected.

Transactions & history

All state objects in visly-state support transactions and history. Transactions serve two purposes: firstly, they ensure that if one mutation within a transaction fails, then all mutations performed within that transaction are rolled back and no React component is updated before all mutations within a transaction have finished. This ensures your state is always valid.

import { state } from 'visly-state'
import { useValue, useMutation } from 'visly-state/hooks'

const appState = state({count: 0})

function Component() {
    const count = useValue(appState, s => s.count)

    const increment = useMutation(appState, s => { 
        s.count += 1 
        if (Math.random() > 0.5) {
            throw new Error("Luck was not on your side")
        }
    })

    const onClick = () => {
        appState.transaction(() => {
            increment()
            increment()
            increment()
        })
    }

    return <button onClick={onClick}>Feeling lucky: {count}</button>
}

Secondly, when used together with the history (undo/redo) functionality built into Visly, visly-state will by default efficiently record all mutations performed on the state and allow you to move backwards and forwards in that history. By baking this functionality into the core state abstraction, it makes it incredibly easy to implement undo/redo functionality for everything within your application. Sometimes though, you want multiple mutations to be committed as a single update to the history stack. Transactions are great for this as well!

import { state } from 'visly-state'
import { useValue, useMutation } from 'visly-state/hooks'

const appState = state({count: 0})

function Component() {
    const count = useValue(appState, s => s.count)
    const increment = useMutation(appState, s => { s.count += 1 })

    const onClick = () => {
        appState.transaction(() => {
            increment()
            increment()
            increment()
        })
    }

    return (
        <div>
            <button onClick={onClick}>Count: {count}</button>
            <button onClick={appState.undo}>undo</button>
        </div>
    )
}

Using visly-state outside react

Thus far we have only showed how we would use visly-state from within a React application. But as mentioned earlier, we also want to be able to make use of our state, selectors, and mutations outside of React, specifically in a Node process. For this, all state objects also come with an imperative API for reading, mutating state, and subscribing to state changes.

import { state } from 'visly-state'

interface CountState {
    count: number
}

const counter = state({ count: 0 })

function getCount(state: CountState) {
    return state.count
}

function incrementCount(state: CountState) {
    state.count += 1
}

const count = counter.get(getCount)
counter.set(incrementCount)
counter.subscribe(value => {})

Open source

We are considering open sourcing visly-state in the future. If you think this library could solve problems for you as well, then please let me know by tweeting at me. If enough people would find this helpful, this is something we would prioritize. For now, though, we're still making big changes to both the implementation and the API of the library to make it as robust and fun to use as possible.

If you'd like to start using Visly, request access here. If this kind of work excites you we are also hiring software engineers to join our remote team!

Visly is a large, complex React app - as you can probably imagine, state management is something we think a lot about