Skip to content
May 4, 2019

Avoiding props drilling with Context

Sensible state management with Context & Hooks

I don't recommend using Context/useReducer anymore. With libraries like Redux Toolkit, Zustand and others, you'll get much better performance without having to re-invent the wheel. If you're working on a trivial project, by alls means do it. If you expect it to grow, do yourself the favour and use something that will take you far.

'Props drilling' is the process of passing down data to your components level after level.

You start simple with two child components. Then you have to add more features and inevitably the two original components have children of their own. You start moving your callbacks further and further down the component hierarchy, but ultimately the code works.

It doesn't feel nice though since we have components that don't care about most of their props, they just act as middlemen. To add more insult to injury:

  • Refactoring is painful
  • Needs more work to avoid unnecessary re-rendings
  • There is a lot of code smell

Here's an example. We have an ItemsList component that semantically groups everything related to the list of our items.

JavaScript
ItemsList.jsx
// ...
const ItemsList = memo(
  ({items, selectedItems, handleSelect, handleDelete, handleSort}) => (
    <div>
      <ItemsControl
        total={items.length}
        selectedTotal={selectedItems.length}
        handleDelete={handleDelete}
        handleSort={handleSort}
      />
      <ItemsTable
        items={items}
        handleDelete={handleDelete}
        handleSelect={handleSelect}
      />
    </div>
  )
);

Now the ItemsTable can be broken down in more components. And the ItemRow could be broken down in even more if the table cell contents are editable.

JavaScript
ItemsTable.jsx
// ...
const ItemsTable = memo(({items, handleDelete, handleSelect}) => {
  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Quantity</th>
          <th>Actions</th>
        </tr>
      </thead>
      <tbody>
        {items.map((item) => (
          <ItemRow
            key={item.id}
            handleDelete={handleDelete}
            handleSelect={handleSelect}
          />
        ))}
      </tbody>
    </table>
  );
});

In medium to large applications, with an overload of information people resort to state management solutions like Redux or MobX.

While we can use one of either, in our use case we don't need a state-management library, as we are only about to use a small subset of their features.

Thankfully the React team in 16.3.0 updated their context API that these libraries rely on.

What is a game-changer for me now, it's the combination with the newly released React Hooks. It offers a greatly streamlined development experience, let's dive in!

Note: You can also Composition. Here's a tweet demostrating this approach https://twitter.com/mjackson/status/1195495535483817984 - To be frank, I believe it's easy to get out of hand, but works wonders for few levels of nesting.

Context + Hooks

Alright, in this case, I'll omit using the usual state pattern and take a page out of Redux's book. Hooks introduced the useReducer hook, and that works wonders since we can use the same dispatch callback all over our codebase.

The "store" declaration is in the same file purely for demonstration purposes

Let's try to redo the previous snippet.

JavaScript
App.jsx
const initialState = {items: {}, selectedItems: {}};
 
const reducer = (state, action) => {
  switch (action.type) {
    case 'DELETE_ITEMS':
      return {...state, items: _.omit(state.items, action.payload.ids)};
    // ...
    default:
      return state;
  }
};
 
const AppContext = createContext(null);
 
export const useAppContext = () => useContext(AppContext); // expose the custom Hook
 
const App = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
 
  // The Provider HOC is mandatory so  that the useAppContext can function
  return (
    <AppContext.Provider value={[state, dispatch]}>
      <Component1 />
      <Component2 />
      <Component3 />
    </AppContext.Provider>
  );
};

Now these ComponentX components, can be totally agnostic of the data that their children need.

Let's move on to our nested ItemRow component. We only care about the dispatch handler for now so let's just introduce this handleDelete. In this naive example, the moment we trigger the button, the component will be unmounted so we don't care about extra memoization in the handleDelete callback.

The reason why DELETE_ITEMS expects an array of ids, is so that it can be reused when we have multi-row controls.

JavaScript
ItemRow.jsx
// ...
// Even better if you can use Webpack alias here
import {useAppContext} from '../../App';
 
const ItemRow = memo(({id, name, description, quantity, dateAdded}) => {
  const [state, dispatch] = useAppContext();
 
  const handleDelete = () =>
    dispatch({
      type: 'DELETE_ITEMS',
      payload: {ids: [id]},
    });
 
  return (
    <tr>
      <td>{name}</td>
      <td>{description}</td>
      <td>{quantity}</td>
      <td>{dateAdded}</td>
      <td>
        <button onClick={handleDelete}>Remove</button>
      </td>
    </tr>
  );
});

Now if we wanted to add more functionality in the above component, that wouldn't be that hard! Let's add the row selection functionality. Previously, we could have to include the handleSelection callback in every component between App & ItemRow. Now, we'll stick to using dispatch with a different type.

Since we use hash-map instead of an array in our state, we can add this to our reducer

JavaScript
ItemsList.jsx
case "TOGGLE_ITEM":
  const { id, value } = action.payload;
  return { ...state, selectedItems: { ...selectedItems, [id]: value } }

Adding the extra table column, as well as the onClick handler and we're good!

JavaScript
ItemRow.jsx
// ...
const ItemRow = useMemo(({ id, name, description, quantity, dateAdded }) => {
  const [state, dispatch] = useAppContext();
 
  // The handleSelection function will cause rerenders, so let's memoize this function
  const handleDelete = useCallback(
    () =>
      dispatch({
        type: 'DELETE_ITEMS',
        payload: { ids: [id] },
      }),
    [id]
  );
 
  const isSelected = !!state.selectedItems[id]
 
  const handleSelection = useCallback(() =>
    dispatch({
      type: 'TOGGLE_ITEM',
      payload: {
        id,
        value: !isSelected,
      },
    }), [id, isSelected]
  );
 
  return (
    <tr>
      <td><input type='checkbox' checked={isSelected} onChange={handleSelection} />
      <td>{name}</td>
      <td>{description}</td>
      <td>{quantity}</td>
      <td>{dateAdded}</td>
      <td>
        <button onClick={handleDelete}>Remove</button>
      </td>
    </tr>
  );
});

In case you wondered why we include dispatch in the useCallback dependencies, that's straight from the docs:

React guarantees that dispatch function identity is stable and won’t change on re-renders. This is why it’s safe to omit from the useEffect or useCallback dependency list.

Fin

That is all.

React hooks are a breath of fresh air and I really enjoy utilizing them more and more. That being said, as we move on to functions rather than classes, it's good to be extra precautious about caching all your expensive computations and callbacks.

The official documentation is probably the best resource out there, so that's all you need. I would strongly recommend using the accompanying eslint plugin, which helps immensely.

As for the Context API, let's keep in mind that it's a simple way to tunnel data to components further down the component tree. It's not a replacement for Redux, even if they tend to accomplish the same goals in small scale projects.

Redux might be using Context internally, but also enables great features like the DevTools plugin, 'time-travel' and more. I would point out you to You Might Not Need Redux by Dan Abramov, because who better to reason about it, that him?