Scalable state management in React

Scalable state management in React

·

7 min read

State management is always a problem to solve in big applications. Many design patterns and libraries emerge to solve some issues by proposing different ways of doing things. In this article, our main focus is on the philosophy of managing your state in applications; most of the things are library-independent.

Do you need a state management library?

You don’t realize it, but you may often skip using any state management library in your application. Native state hooks in React and some composition patterns can accomplish the majority of things.

Do you want to communicate changes from one part of the tree to another?

Pull the state up to a common parent and pass it via props or context.

Does my state have too many properties?

useReducer is very powerful; you should pay attention to native stuff.

I have asynchronous stuff going on in my app.

useQuery, useSWR, and useEffect are perfect candidates to solve your problem.

I’m not against state management libraries, but once you do a npm i xyz, you are bound to think in their paradigm, and now you want the library to do everything for you. Things that don’t make sense in the state will be moved to the state; things that are derived from states will see their property in the state. You end up chaining yourself to thinking only in the paradigm of that library.

Questions that matter

  • I have a deeply nested leaf item that modifies and affects the entire app.

  • I have too many features that are dependent on each other.

  • I have other feature states spread out in my own feature, coupling things.

  • I find it difficult to communicate between features.

Philosophy

A product manager tries to pack in many features in their application; however, that may be good for the user but is a horror for developers. If you have such a project manager, I feel you, bro! 🥲

But as developers, it is our responsibility that feature addition and deletion should be plug-and-play; even your project manager should be able to do it by pressing the harmless* button, and your code should ensure that it is harmless.

Mind your own business

Your feature should always be independent of any other feature in the app.

It should not directly poke its nose into the business logic of other features and strictly remind them to “Mind their own business” when they try to poke their nose into your stuff.

When you develop something, make sure you write it in such a way that it can run in isolation, that is the core of philosophy.

Make your code in such a way that if one feature speaks English, the other feature should speak Chinese, What I’m stressing is that the features should not have any knowledge of each other; they should not be able to talk to each other directly.

But you need to send messages across, and that is why you need a mediator, translator, or orchestrator.

Say I'm building something similar to notion

I see two main items:

  • Sidebar

  • Editor

When a page is clicked in the sidebar, it opens up in the editor.

When the editor heading changes, the text also changes in the sidebar.

The context menu in the sidebar allows you to rename the editor headline.

Sidebar state:

state = [
  { 
    id: string; // uniq page id
    label: string; // title of the page
    value: string; // page value
  },
  ...
]

Editor state:

state = {
    id: string;
    title: string;
    content: [
        {
            tag: string;
            content: []
        },
        ...
    ]
}

Note: Each has its state and not a common state so when I update text in the sidebar, that will update its own state and editor state.

A naive implementation would look like this:

onRename = () => {
 // update the state in sidebar
  sidebarDispatch({ 
      type: 'sidebar/update_item', 
        payload: { 
            id: '..', 
            title: '...' 
        } 
    });

    // update the same in editor
    editorDispatch({
        type: 'editor/update_text',
        payload: {
           title: '...'
        }
    })
}

This onRename function would be present in both modules, i.e., editor and sidebar, but this is what causes coupling. Although we want to do exact same thing but not spread dispatch across modules,.

We will pull this into this logicglobalOrchestrator, which acts as a mediator between the two. It may look like this:

globalOrchestrator({ type: 'rename', paylod: { id: '..', title: '...' } })

The globalOrchestrator would exactly consume dispatches but abstract them in a way that two modules only know the globalOrchestrator.

The editor thinks that doing so globalOrchestrator > rename will update the editor's own text and the editor states, it does not know the sidebar.

The same is true of the story of the sidebar, where it only knows globalOrchestrator > rename updates its own state.

That is how cross-communication happens and every feature has its own business in application.

We have eliminated coupling, you may not realize it but let’s say in the future you need to remove the sidebar as a feature as a whole What you will do is delete the sidebar directory and remove the sidebar dispatch from globalOrchestrator > rename

Never show your true self

You, as a feature, should always… always create abstractions over your internal state, hooks, dispatch, useSelector/useAtom/useSlice.

  • Never expose your entire state; expose atoms or slices of state in hooks.
const useSidebarOpenState = () => {
 // 1. mark internal state as unsafe
 // 2. your lib specific way to get slice/atom
 return useSidebarState__UNSAFE(state => state.open);
}
  • Never expose dispatches or actions
const useSidebarOpenDispatch = () => {
 const dispatch = useSidebarDispatch__UNSAFE();
 const open = () => {
     dispatch(sidebarOpenAction());
 };
 return open;
}
  • You may choose to combine the two
const useSidebarOpen = () => [useSidebarOpenState(), useSidebarOpenAction()]
  • Expose as little as possible, create and derive values in hooks itself.
const useSidebarHasItemState = () => {
 // instead of having items exposed we should 
 // Start with exactly what was required
 // yes you may need all of the items but that is diff
 return useSidebarState__UNSAFE(state => state.items.length > 0);
}
  • Binding at the top

Structure your code in such a way that you create all global bindings, handlers, and complex logic at the root of the feature component and make individual nodes of the tree pure, dumb, and presentational components.

sidebar
 |____ components
 |        |___ search
 |        |___ section
 |               |___ Header.tsx
 |               |___ ItemList.tsx
 |
 |____ hooks
 |      |___ useInfiniteScroll.ts
 |
 |____ views
 |       |___ Root.tsx
 |
 |____ Orchestrator.ts

The above structure is how we would like to visualize and lay out our features.

  • Anything inside of components has to be pure and driven by props and may have internal useState, but should in no way be bound to any global/environmental state/handler/actions.

  • Anything inside of hooks is hooks used inside our feature directory or may be exposed to the outside world, these may contain hooks with global bindings and pure hooks with internal usage.

    Only hooks with internal usage may be used in the components dir, any hook with global bindings will not be used in the components dir.

    Usually, it is a good idea to localise internal hooks within the component dir,

    for example:
    useInfiniteScroll load sidebar items on the scroll can be within sidebar/components/section and used inside of ItemList.tsx.

  • Anything inside of views creates bindings to the environmental state and is distributed as props to underlying components. These are the perfect sites to consume global state and simplify handlers.

    Say our sidebar had two tabs, admin and user, where if switched they would render their own tree in the sidebar. They can have their individual roots in
    views dir.

    A Root is the mounting point of the feature component; it may branch into several roots, for example AdminRoot or UserRoot with only the purpose of simplifying the underlying tree and branching slowly into a simpler tree. Each branch of Root tries to simplify stuff so that underlying trees are super simple.

  • These roots are where we may consume our global-bound hooks, handlers, and utils, everything should be passed on as props. Props drilling may be an issue but remember that props usually tend to redistribute themselves as we nest and if 10 props were passed on during the 4–5th nesting or drilling, only 2–3 props may be required. If too props drilling too much, use context here.

  • Note that it is not advised to directly bind or use global utils inside components but you may choose to wrap those in a SidebarOrchestrator that will exist to bind global to sidebar local, use this Orchestrator from props, create a SidebarOrchestrator hook, or use simple utils; that is up to you to decide.

In the future, you could just re-export to npm and use the sidebar with your own views and bindings in a different application or for your sister company.

Did you find this article valuable?

Support Aniket Jha by becoming a sponsor. Any amount is appreciated!