Passing Data Between Components using props, useReducer, and useContext

We will explain it with an example (which is NOT a to-do app)

·

6 min read

The good thing about React (or any other good SDKs, or toolkits, or programming languages for that matter) is that you can get the same result using different methods. In this post, I would like to demonstrate how you can pass data between different components using different methods. We will introduce the concept of reducer, context, and how to use them through the useReducer and useContext hooks.

Imagine a typical app which has a top toolbar and a bottom view, which shows a list of items:

export default App = () => {
   return (
      <Main>
         <TopBar />
         <ItemListView />
      </Main>
   );
}

It is very common that the ItemListView has to communication with the TopBar. For example, when you click and select an item, the TopBar shows some action buttons for you to do with it. How can you implement this?

Method 1: Through props

The most straightforward way is to use props. We can write a handler function and pass it to ItemListView, which, when called, will change a local state variable, which is passed to TopBar. Like this:

export default App = () => {
   const [ selection, setSelection ] = useState(0);
   return (
      <Main>
         <TopBar selection={selection} />
         <ItemListView onSelectionChanged={setSelection} />
      </Main>
   );
}

Somewhere in ItemListView, the handler is called when needed to:

export default ItemListView = (props) => {
   // ....
   const itemClicked = (item) => {
      //...
      props.onSelectionChanged(item.id);
   }
   // ...
}

This method is easy and straightforward. However, if you have many data that need to be communicated, you may get into this:

<ItemListView
   onSelectionChanged={setSelection}
   onItemChanged={handleItemChanged}
   onSomeEvent1={handleEvent1}
   onSomeEvent2={handleEvent2}
   ...
/>

You can write another function to consolidate them, with complicated state logic and management. But why bother? You can always use...

Method 2: useReducer

Redux, and its functional component hook counterpart useReducer, is the perfect tool for such complex state management across different components. Let's see how we can use useReducer here.

(I once heard a speaker said: "...and then we use useState state management..." and I was surprised he didn't stutter.)

Simply put, a reducer is merely a function which takes two arguments: the current "state" object and an "action" object, and returns a new state object. When you feed a reducer to useReducer(), it returns an array, similar to useState():

const [ state, dispatch ] = useReducer(reducer);

Where state is the state object for you to consume, and dispatch a function for you to dispatch actions to the reducer.

Let's use our app again as an example. Suppose when we click an item, the TopBar goes into "select mode", such that there will be some UI changes, and at the same time store the selected item id. We can define our reducer like this:

const reducer = (state, action) => {
   switch (action.type) {
      case 'TURN_ON_SELECT_MODE':
         return {...state, selectMode: true};
      case 'TURN_OFF_SELECT_MODE':
         return {...state, selectMode: false};
      case 'SET_ITEM':
         return {...state, selectedItem: action.value};
   }
}

Note:

  • It is a common practice that the action object has two properties: type and value, although it doesn't have to be.
  • You cannot change the state directly. State change is achieved by returning a new state object. Also, when you return, you must return the entire object, not just the value. Therefore, we use the spread syntax ... to merge the new value with the existing ones, and return the new state object.

It's time to use it in our app:

// App.js
export default App = () => {
   const [ state, dispatch ] = useReducer(reducer);
   return (
      <Main>
         <TopBar selectMode={state.selectMode} />
         <ItemListView dispatch={dispatch} />
      </Main>
   );
}

// ItemListView.js
export default ItemListView = (props) => {
   // ....
   const itemClicked = (item) => {
      //...
      props.dispatch({ type: 'TURN_ON_SELECT_MODE' });
      props.dispatch({ type: 'SET_ITEM', value: item.id });
   }
   // ...
}

The good thing about useReducer (and Redux) is that it packs state values/object and action together, so you do not need 10x props for different values and handlers. Also, since a state value can only be modified through action, it will never go into some incoherent state, which makes debugging state-related issues much, much easier.

But what if ItemListView consists of many many child components, where some, if not most, of them also need access to dispatch? We can certainly pass it down through props, but it's tedious. Hm... I wonder if there's some kind of global variables...

Method 3: useContext

Well, turns out there is! But it's in the form of context.

None other than the official React site summarizes the use of context better:

Context provides a way to pass data through the component tree without having to pass props down manually at every level.

Think of it like a magic, hyper-dimensional secret knapsack that you can put things (variables) in, and when needed, you use the hook useContext to retrieve the things back. Difficult to understand? Let's see it in action!

First of all, we define the context. Let's call it TopBarContext:

// TopBarContext.js
export const TopBarContext = React.createContext(null);

Then, we wrap our whole app, or the highest node possible that will use this context, with the context's Provider. Also, in the process, we state the "things" we want to store, which in this case is the dispatch function:

// App.js
import { TopBarContext } from './TopBarContext';

export default App = () => {
   const [ state, dispatch ] = useReducer(reducer);
   return (
      <TopBarContext.Provider value={dispatch}>
         <Main>
            <TopBar selectMode={state.selectMode} />
            <ItemListView />
         </Main>
      </TopBarContext.Provider>
   );
}

That's it! Note that we don't need to pass dispatch to ItemListView through props now.

In anywhere (anywhere under TopBarContext.Provider, that is) you want to use the dispatch, you use the useContext hook to retrieve it back:

// ItemListView.js
import { TopBarContext } from './TopBarContext';

export default ItemListView = () => {
   // ....
   const dispatch = useContext(TopBarContext);
   const itemClicked = (item) => {
      //...
      dispatch({ type: 'TURN_ON_SELECT_MODE' });
      dispatch({ type: 'SET_ITEM', value: item.id });
   }
   // ...
}

Which method should I use?

Each method has its pros and cons. Obviously, using props is the most straightforward, but can get tedious very quickly. Using context is the ideal way if your app is huge, and the value you wish to share is quite fundamental and universal (normally we see examples like theme, authentication status etc.) But it could be an overkill and lead to a difficult-to-understand codes. Reducer is somewhere in between... So, it really depends on the situation. Like I said at the beginning, it's a good thing to have different ways to do thing.

Hopefully this post can give you a basic understanding on how to use the props, reducer, and context to pass data between React components. If you want to dig deeper, these are some of the best tutorials on the topics: