loading...

React Context: a Hidden Power

alexkhismatulin profile image Alex Khismatulin ・3 min read

Last week I had to implement the new React Context API for a React 15 project. Migrating to React 16 was not the option due to a big codebase so I headed to React sources for references.
The first thing I noted was the second argument of the createContext function:

export function createContext<T>(
  defaultValue: T,
  calculateChangedBits: ?(a: T, b: T) => number,
): ReactContext<T> {

The second argument is not mentioned in the React docs so started discovering what that is. After some investigation I found out that there's an optimization that can be applied to React Context.

So what does it actually do?

React Context allows its consumers to observe certain bits of a bitmask produced by the calculateChangedBits function that can be passed as the second argument to createContext. If one of the observed bits changes, a context consumer gets re-rendered. If not, it's not going to do an unneeded re-render. Sounds great! Let's see how it works in practice.

Before we start

If you're not familiar with bitwise operators, check out this MDN page.

A Sample App

I created a simple Ice Cream Constructor app that has two selects and shows a list of available options based on selected values. The filter is a simple React Context that holds the state of selected values and provides an API for its consumers to get a current filter state and update it. You can check out the full demo here.

First of all, let's define an object that's going to map context consumers to bits they observe:

export default {
  fruit: 0b01,
  topping: 0b10,
};

0b is a binary prefix meaning that a number following after it is binary. By putting 1s and 0s we tell what bits are going to be observed. There won't be any observed bits if we put 0, and every bit is observed if we put all 1s. In our example we say that fruit is going to observe the first bit and topping is going to observe the second bit.

calculateChangedBits

Now Let's create a filter context:

import React from 'react';
import observedBitsMap from './observedBitsMap';

const calculateChangedBits = (currentFilter, nextFilter) => {
  let result = 0;

  Object.entries(nextFilter.filter).forEach(([key, value]) => {
    if (value !== currentFilter.filter[key]) {
      result = result | observedBitsMap[key];
    }
  });

  return result;
};

const initialValue = {
  filter: {
    fruit: 'banana',
    topping: 'caramel',
  },
};

export const FilterContext = React.createContext(initialValue, calculateChangedBits);

calculateChangedBits is passed as the second argument to React.createContext. It takes current context value and new context value and returns a value that represents changed context values that are changed.

unstable_observedBits

While result of calling calculateChangedBits represents the whole change, unstable_observedBits tells what particular bits of the whole change are going to trigger a context consumer update. It's passed as the second argument to React.useContext:

import React from 'react';
import observedBitsMap from './observedBitsMap';
import { FilterContext } from './FilterContext';

const FilterItem = ({ name, children }) => {
  const context = React.useContext(FilterContext, observedBitsMap[name]);

  const onChange = React.useCallback(
    (e) => {
      context.onFilterChange(e);
    },
    [context.onFilterChange],
  );

  return children({ name, onChange, value: context.filter[name] });
}

If you want to use a regular JSX Context.Consumer you can pass unstable_observedBits as a prop:

<FilterContext.Consumer unstable_observedBits={observedBitsMap[name]}>
...

If unstable_observedBits is passed, consumer is going to be updated only if the result of bitwise AND on what we got from calculateChangedBits's execution and unstable_observedBits is not equal to 0.

Let's see how it works:
Alt Text

Limitations

As you can see from the unstable_observedBits name, this is an unstable experimental feature. Every time a context value changes React shows a warning:
Alt Text

Also, there's a limitation on the number of bits that can be observed. It is restricted by the max integer size in V8 for 32-bit systems. This means that we can't effectively re-render observe more than 30 different consumers.

Conclusion

Even though React Context API provides a great optimization opportunity, I don't think it should be widely used. This whole thing is more about exploring what the library hides rather than finding something for usual usage. If you think that you want to apply this optimization in your project, ask yourself "why are my renders so slow that I need to use a deep optimization?" question first.

I guess that this feature is going to be used mostly in libraries even when it turns to stable. But I'm really interested in what direction would the implementation evolve.

Discussion

markdown guide
 

Oh, I didn't know about second parameter of createContext. As you said, it's probably not widely used, but good to know. Maybe will be useful in the future projects, who knows? Thank you for this article.

 

Thanks! Happy to hear that it helps to learn something new

 

This post is a gem! Thanks for explaining so detailed. I've used this package github.com/dai-shi/react-tracked for a while, it also uses the calculateChangedBits under the hood. With your explanation now I can understand the source code.

Really appreciate!

 

Thanks!
I saw react-tracked but didn’t get a chance to use it yet. Also I didn’t know that it uses calculateChangedBits. Great real-world example for me and those who interested!