Managing State Outside of React

August 22, 2023 4 minute read

My blog is something of a trophy case of mine. Like a hunter who hangs the antlers of a stag on their wall, every time I solve a technical problem, I feel obliged to write about it here.

A few months ago I got a new UI to build.

It was supposed to allow people to interact with a workflow system. Users needed a way to create and edit these very complex and deeply nested data structures that a back-end used to orchestrate operations.

I like to think of the front-end as an abstraction over the back-end.

And even though it would’ve been much simpler to create yet another application riddled with forms, I decided that a “visual” editor would provide a better API over the underlying functionality.

State is complex

On an abstract level the end product I had to visualize was a dynamic ordered graph.

Through a combination of open-source visualization libraries and my own positioning algorithm, I managed to get something half-decent on the screen. Modify the data, shove it into state, and voilla!

But editing a massive, deeply nested object is a lot harder when you’re keeping it in state.

Updating the graph is no walk in the park either. If you add or remove a new node in the middle of the graph you have to change the position of everything around it.

I started with an imperative approach at first.

Whenever a graph change happened, I had custom logic that traversed it and updated all impacted nodes. This quickly proved to be a brittle way to solve the problem, and turned into a jQuery-esque hunt for edge cases.

Cornering out the corner-cases

Every new piece of logic added, broke something before it. And while an extensive test suite would’ve prevented uncought bugs, the graph would’ve become a complexity hotbed.

Being able to catch the bugs doesn’t make working in this environment any easier.

This is the moment when you have to either double-down on your first approach or take a step back and rethink the whole implementation. Thankfully, I went with the latter and this tweet by Tanner Linsley bubbled in my mind.

We’ve all tried to mutate an object, expecting it to magically appear on the screen before we learned how state worked in React. But what if I used this as a feature instead?

Trade complexity for performance

I didn’t need to manage all my state inside React components and custom hooks. As long as I had a way to commit the update to React, I could create my own object or class to deal with the changes by mutating the graph.

This removed the complexity outside of the components, but it didn’t make the code any less brittle.

To resolve this, I traded performance for lower complexity. Instead of applying the changes to the graph surgically everytime a change occurred, I recalculated the whole data structure.

By changing the input, I could regenerate the whole graph, reusing the logic that was already stable. The size of the data structure and the frequency of changes were low enough to allow this.

I only needed a mechanism to communicate the changes with React.

Manage state outside of React

In the last couple of years I’ve become a big advocate of keeping business logic outside components. So any functionality that goes beyond a simple event handler, gets moved to a custom hook.

export default function Graph({ input }) {
  const { graph } = useGraph(input)

  return (
    <ThirdPartyVisualizationComponent
      nodes={graph.nodes}
      edges={graph.edges}
    />
  )
}

The component stays true to the core idea of receiving data and rendering it, remaining oblivious of the format the library needs, the format the API uses to return data, and how one is transformed in the other.

Now let’s see how we can abstract away the conversion details.

import createGraphConverter from '@converter'

export default function useGraph(input) {
  const graphConverter = useRef(null)
  const [graph, setGraph] = useState({})

  useEffect(() => {
    graphConverter.current = createGraphConverter(input)
    setGraph(graphConverter.current.convert())
  }, [])

  const addNode = (node) => {
    setGraph(graphConverter.current.addNode(node))
  }

  const removeNode = (node) => {
    setGraph(graphConverter.current.addNode(node))
  }

  return {
    graph,
    addNode,
    removeNode,
  }
}

The hook is no more than an intermediary that connects the graphConverter to the React world. In the context of hexagonal/clean architecture that would be a port, connecting the domain logic to React as an external interface.

We use a ref to keep a single instance of the converter and set the graph only when the whole conversion is done and the new graph is ready to be displayed.

Underneath, the converter does multiple mutating operations, unknowing that they are used in the browser. You can lift the same code and use it with another front-end framework or even in Node.

The necessity of layers

Putting aside the reduced complexity and ease of working with this code, are the extra layers necessary?

Realistically, we’d never need to move the converter to another framework. But boundaries are a good proxy metric for how decoupled our code is.

Having layers in place means we can modify the UI seperately from the logic that fuels it. A change in the converter will only affect it and its tests, leaving the UI untouched (as long as the data structure is the same).

Easier handling of changes is what I’m optimizing for.

Tao of Node

Learn how to build better Node.js applications. A collection of best practices about architecture, tooling, performance and testing.