React state with a fragmented store

aralroca

Aral Roca

Posted on September 24, 2021

React state with a fragmented store

There are many ways to manage the React state between many components: using libraries like Redux, MobX, Immer, Recoil, etc, or using a React Context.

After using several of them, I personally choose React Context because of its simplicity. To use a React Context to manage the state you have to put the state in the Provider along with the method to update it. Then you can consume it from the Consumer.

However, the problem with React Context is that if you change the value of a single field of the state, instead of updating the components that use only this field, all components that use any field from the state will be re-rendered.

Unfragmented store schema


Unfragmented state in React Context

In this article I'm going to explain the concept of "fragmented store" to solve this, and how to use it in a simple and easy way.

What is a fragmented store

The fragmented store makes it possible to consume each field of the store separately. Since most of the components will consume few fields of the whole store, it's not interesting that they are re-rendered when other fields are updated.

Fragmented store schema


Fragmented store in React Context

To solve this with React Context you have to create a context for each field of the store, which is not very feasible due to its difficulty.

// โŒ  Not recommended
<UsernameProvider>
  <AgeProvider>
    {children}
  </AgeProvider>
</UsernameProvider>
Enter fullscreen mode Exit fullscreen mode

Naturally, if we have very few properties in the "store" it could work. But when we start to have too many, there will be too much logic implemented to solve the problem of re-rendering, since it would be necessary to implement each context for each property.

However, I have good news, it can be automatically created.

How to use a fragmented store

I created a tiny library (500b) called fragmented-store to make it super simple and easy to use. It uses React Context underneath (I'll explain later what it does exactly).

Fragmented store logo


Fragmented store logo

Create context + add the Provider

Just as we would go with the React Context, we need to create the context and add the provider to the application. We'll take this opportunity to initialize the store to the data we want at the beginning.

import createStore from "fragmented-store";

// It is advisable to set all the fields. If you don't know the 
// initial value you can set it to undefined or null to be able 
// to consume the values in the same way
const { Provider } = createStore({
  username: "Aral",
  age: 31,
});

function App() {
  return (
    <Provider>
     {/* rest */} 
    </Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Consume one field

For the example, we will make 2 components that consume a field of the store. As you'll see, it's similar to having a useState in each component with the property that you want, with the difference that several components can share the same property with the same value.

import createStore from "fragmented-store";

// We can import hooks with the property name in camelCase.
// username -> useUsername
// age -> useAge
const { Provider, useUsername, useAge } = createStore({
  username: "Aral",
  age: 31,
});

function App() {
  return (
    <Provider>
     <UsernameComponent />
     <AgeComponent /> 
    </Provider>
  );
}

// Consume the "username" field
function UsernameComponent() {
  const [username, setUsername] = useUsername();
  return (
    <button onClick={() => setUsername("AnotherUserName")}>
      Update {username}
    </button>
  );
}

// Consume the "age" field
function AgeComponent() {
  const [age, setAge] = useAge();
  return (
    <div>
      <div>{age}</div>
      <button onClick={() => setAge((s) => s + 1)}>Inc age</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

When the AgeComponent updates the age field only the AgeComponent is re-rendered. The UsernameComponent is not re-rendered since it does not use the same fragmented part of the store.

Consume all the store

In case you want to update several fields of the store, you can consume the whole store directly. The component that consumes all the store will be re-render for any updated field.

import createStore from "fragmented-store";

// Special hook useStore
const { Provider, useStore } = createStore({
  username: "Aral",
  age: 31,
});

function App() {
  return (
    <Provider>
     <AllStoreComponent />
    </Provider>
  );
}

// Consume all fields of the store
function AllStoreComponent() {
  const [store, update] = useStore();

  console.log({ store }); // all store

  function onClick() {
    update({ age: 32, username: "Aral Roca" })
  }

  return (
    <button onClick={onClick}>Modify store</button>
  );
}
Enter fullscreen mode Exit fullscreen mode

And again, if we only update some fields, the components that consume these fields will be re-rendered while other components that consume other fields won't!

// It only updates the "username" field, other fields won't be updated
// The UsernameComponent is going to be re-rendered while AgeComponent won't :)
update({ username: "Aral Roca" }) 
Enter fullscreen mode Exit fullscreen mode

You don't need to do this (even if it's supported):

update(s => ({ ...s, username: "Aral" }))
Enter fullscreen mode Exit fullscreen mode

With this only the components that consume the username field with the useUsername hook would be re-rendered.

How is implemented underneath

The fragmented-store library is a single very short file. It's similar of what we'd manually do to create several React Contexts for each property. It automatically creates everything you need to consume and update them (hooks).

import React, { useState, useContext, createContext } from 'react'

export default function createStore(store = {}) {
  const keys = Object.keys(store)
  const capitalize = (k) => `${k[0].toUpperCase()}${k.slice(1, k.length)}`

  // storeUtils is the object we'll return with everything
  // (Provider, hooks)
  //
  // We initialize it by creating a context for each property and
  // returning a hook to consume the context of each property
  const storeUtils = keys.reduce((o, key) => {
    const context = createContext(store[key]) // Property context
    const keyCapitalized = capitalize(key)

    if (keyCapitalized === 'Store') {
      console.error(
        'Avoid to use the "store" name at the first level, it\'s reserved for the "useStore" hook.'
      )
    }

    return {
      ...o,
      // All contexts
      contexts: [...(o.contexts || []), { context, key }],
      // Hook to consume the property context
      [`use${keyCapitalized}`]: () => useContext(context),
    }
  }, {})

  // We create the main provider by wrapping all the providers
  storeUtils.Provider = ({ children }) => {
    const Empty = ({ children }) => children
    const Component = storeUtils.contexts
      .map(({ context, key }) => ({ children }) => {
        const ctx = useState(store[key])
        return <context.Provider value={ctx}>{children}</context.Provider>
      })
      .reduce(
        (RestProviders, Provider) =>
          ({ children }) =>
            (
              <Provider>
                <RestProviders>{children}</RestProviders>
              </Provider>
            ),
        Empty
      )

    return <Component>{children}</Component>
  }

  // As a bonus, we create the useStore hook to return all the
  // state. Also to return an updater that uses all the created hooks at
  // the same time
  storeUtils.useStore = () => {
    const state = {}
    const updates = {}
    keys.forEach((k) => {
      const [s, u] = storeUtils[`use${capitalize(k)}`]()
      state[k] = s
      updates[k] = u
    })

    function updater(newState) {
      const s =
        typeof newState === 'function' ? newState(state) : newState || {}
      Object.keys(s).forEach((k) => updates[k] && updates[k](s[k]))
    }

    return [state, updater]
  }

  // Return everything we've generated
  return storeUtils
}
Enter fullscreen mode Exit fullscreen mode

Demo

I created a Codesandbox in case you want to try how it works. I added a console.log in each component so you can check when each one is re-rendered. The example is super simple, but you can try creating your own components and your state.

Conclusions

In this article I've explained the benefits of the "fragmented store" concept and how to apply it with React Context without the need to manually create many contexts.

In the example of the article and the fragmented-store library the fragmentation level is only at the first level for now. The library I've implemented is in a very early stage and there are certainly a number of improvements that could be made. Any proposal for changes can be made on GitHub as the project is open source and will be very well received:

๐Ÿ’– ๐Ÿ’ช ๐Ÿ™… ๐Ÿšฉ
aralroca
Aral Roca

Posted on September 24, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

ยฉ TheLazy.dev

About