React applications are essentially a tree of components that communicate data with each other. Passing data between components is usually painless. However, as the application tree grows, it becomes more difficult to pass data around while maintaining a readable code base.
Suppose we have the following tree structure:
Here is a simple tree with three levels. In this tree, nodes D and E both manipulate some similar data: Suppose the user enters some text in node D, and we want to display that text in node E.
How do we pass data from node D to node E?
This paper introduces three feasible methods to solve this problem:
- Prop drilling
- Redux
- React’s context API
The purpose of this article is to compare these approaches and show that when solving a common problem, such as the one we just described, you can stick with the React Context API.
Method 1: Prop drilling
The way to do this is to pass data from the child to the parent via props, and from the parent to the child, as in D->B->A and then A->C->E.
The idea here is to pass input data from node D to the state of node A using A function triggered by onUserInput from child to parent, and then we pass that data from the state of node A to node E.
We start with node D:
class NodeD extends Component {
render() {
return (
<div className="Child element">
<center> D </center>
<textarea
type="text"value={this.props.inputValue} onChange={e => this.props.onUserInput(e.target.value)} /> </div> ); }}Copy the code
As the user types something, the onChange listener will trigger the function from prop’s onUserInput and pass in user input. This function on node D prop will trigger another function onUserInput on node B prop as follows:
class NodeB extends Component {
render() {
return (
<div className="Tree element"> <center> B</center> <NodeD onUserInput={inputValue => this.props.onUserInput(inputValue)} /> </div> ); }}Copy the code
Finally, when the root node A is reached, onUserInput fires in node B Prop to change the state in node A to the value entered by the user.
class NodeA extends Component {
state = {
inputValue: ""
};
render() {
return (
<div className="Root element"> <center> A </center> <NodeB onUserInput={inputValue => this.setState({ inputValue: inputValue })} /> <NodeC inputValue={this.state.inputValue} /> </div> ); }}Copy the code
The inputValue value is passed from node C to child node E via props:
class NodeE extends Component {
render() {
return (
<div className="Child element"> <center> E </center> {this.props.inputValue} </div> ); }}Copy the code
Seeing it already adds some complexity to our code, even if it’s just a small example. Can you imagine what happens when applications grow? 🤔
This approach depends on the depth of the tree, so for greater depth we need to go through larger component layers. This might be too long to implement, too repetitive, and add complexity to the code.
Method 2: Use Redux
Another approach is to use a state management library like Redux.
Redux is a predictable state container for JavaScript apps. The state of our whole application is stored in an object tree within a single store, which your app components depend on. Every component is connected directly to the global store, and the global store life cycle is independent of the components’ life cycle.
Let’s start by defining the state of the application: the data we’re interested in is what the user enters into node D. We want to provide this data to node E. To do this, we can provide this data in store. Node E can then subscribe to it to access the data.
We’ll go back to store a little bit.
Step 1: Define Reducer
Next is to define our Reducer. Our Reducer specifies how the state of the application changes in response to actions passed to the Store.
The reducer defined is as follows:
const initialState = {
inputValue: ""
};
const reducer = (state = initialState, action) => {
if (action.type === "USER_INPUT") {
return {
inputValue: action.inputValue
};
}
return state;
};
Copy the code
Before the user enters anything, we know that our state data or inputValue will be an empty string. Therefore, we use the empty string inputValue to define the default initial state for the Reducer.
The logic here is that once the user types something on node D, we “trigger” or issue an action to update the state, regardless of what the user enters. By “update” I do not mean a “mutation” or change to the current state, but a return to a new state.
The if statement maps the action dispatched to the new state to be returned based on its type. So we already know that the action dispatched is an object that contains a type key. How do we get the user input value for the new state? We simply added another key named inputValue to the action object, and in our reducer block we made the inputValue of the new state have that inputValue action.inputvalue. So the behavior of our application will follow this architecture:
{ type: "SOME_TYPE", inputValue: "some_value" }
Copy the code
Finally, our Dispatch declaration will look like this:
dispatch({ type: "SOME_TYPE", inputValue: "some_value" })
Copy the code
When we invoke the Dispatch statement from any component, we pass in the type of operation and the user input value.
Ok, now we know how the application works: in our input node D, we dispatch an action of type USER_INPUT and pass in whatever value the user just entered, and in our display node E we pass in the current value that the user entered as the state of the application.
Step 2: Define Store
To make our store available, we pass it to the Provider component from the React-Redux import, and then wrap the App inside. Since we know that nodes D and E will use the data in the Store, we want the Provider component to contain the common parent of these nodes, so either the root node A or the entire App component. Let’s select the App component to include in our Provider component:
import reducer from "./store/reducer";
import { createStore } from "redux";
import { Provider } from "react-redux";
const store = createStore(reducer);
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById("root"));Copy the code
At present, we have set store and reducer. Next, we will do things in node D and node E.
Step 3: Implement user input logic
First let’s look at node D. We’re interested in what the user entered in the Textarea element. This means two things:
- We need to implement the onChange event listener and Store whatever the user enters in the Store.
- We need to store the value property as a textarea value in the store.
But before we can do any of this, we need to set up a few things:
We first need to connect the node D component to our Store. To do this, we use the connect() function in React-redux. It provides connected components with the data required by the Store, as well as functionality that can be used to dispatch operations to the Store.
This is why we use the two mapStateToProps and mapDispatchToProps which deal with the store’s state and dispatch respectively. We want our node D component to be subscribed to our store updates, as in, our app’s state updates. This means that any time the app’s state is updated, mapStateToProps will be called. The results of mapStateToProps is an object which will be merged into our node D’s component props. Our mapDispatchToProps function lets us create functions that dispatch when called, and pass those functions as props to our component. We will make use of this by returning new function that calls dispatch() which passes in an action.
In our example, for the mapStateToProps function, we are only interested in inputValue, so we return an object {inputValue: state.inputValue}. For mapDispatchToProps, we return a function onUserInput that takes the input value as an argument and dispatches the action using type USER_INPUT. The returned new state object mapStateToProps and onUserInput functions are merged into the props of our component. So we define our component:
class NodeD extends Component {
render() {
return (
<div className="Child element">
<center> D </center>
<textarea
type="text"
value={this.props.inputValue}
onChange={e => this.props.onUserInput(e.target.value)}
/>
</div>
);
}
}
const mapStateToProps = state => {
return {
inputValue: state.inputValue
};
};
const mapDispatchToProps = dispatch => {
return {
onUserInput: inputValue =>
dispatch({ type: "USER_INPUT", inputValue: inputValue })
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(NodeD);
Copy the code
We are done with node D! Now let’s go to node E, where we want to display user input.
Step 4: Implement user output logic
We want to display user input data on this node. We already know that this data is basically the current state of our application, just like our Store. So eventually, we want to access the store and display its data. To do this, we first need to subscribe the node E component to the store update using the connect() function with mapStateToProps, the same function we used earlier. After that, we just need to access the store data from the component’s props using this.props. Val:
class NodeE extends Component {
render() {
return (
<div className="Child element">
<center> E </center>
{this.props.val}
</div>
);
}
}
const mapStateToProps = state => {
return {
val: state.inputValue
};
};
export default connect(mapStateToProps)(NodeE);
Copy the code
We finally finished Redux! 🎉 you can check out what we just did here.
In the case of a more complex example, such as using a tree of components with more shared/operational stores, we would need to use the mapStateToProps and mapDispatchToProps functions on each component. In this case, it might be more sensible to separate our action types and reducers from the components by creating separate folders for each component.
. Who has the time?
Method 3: Use the React context API
Now let’s redo the same example using the context API.
The React Context API has been around for a while, but it wasn’t until React version 16.3.0 that it became safe to use in production. The logic here is close to that of Redux: We have a context object that contains some global data that we want to access from other components.
First, we create a context object that contains the initial state of the application as the default state. Then we create a Provider and a Consumer component:
const initialState = {
inputValue: ""
};
const Context = React.createContext(initialState);
export const Provider = Context.Provider;
export const Consumer = Context.Consumer;
Copy the code
Our Provider component has all the components as children from which we want to access the context data. Just like the Redux version above the Provider. To extract or manipulate context, we use the Consumer equivalent of a component.
We want our Provider component to wrap the entire App, just like the Redux version above. However, this Provider is a little different from the previous one we’ve seen. In our App component, we initialize the default state with some data that we can share with our Provider component by supporting the values.
In our example, we will share this.state.inputValue with functions that manipulate state, such as our onUserInput function.
class App extends React.Component {
state = {
inputValue: ""
};
onUserInput = newVal => {
this.setState({ inputValue: newVal });
};
render() {
return (
<Provider
value={{ val: this.state.inputValue, onUserInput: this.onUserInput }}
>
<div className="App"> <NodeA /> </div> </Provider> ); }}Copy the code
Now we can continue to use the Consumer component to access the Provider component’s data 🙂
For node D where user input data:
const NodeD = () => {
return (
<div className="Child element">
<center> D </center>
<Consumer>
{({ val, onUserInput }) => (
<textarea
type="text"
value={val}
onChange={e => onUserInput(e.target.value)}
/>
)}
</Consumer>
</div>
);
};
Copy the code
For node E where we display user input:
const NodeE = () => {
return (
<div className="Child element ">
<center> E </center>
<Consumer>{context => <p>{context.val}</p>}</Consumer>
</div>
);
};
Copy the code
We have completed the context API version of our example! 🎉 Isn’t that hard? Check it out here
What if we want access to more of the context’s components? We can wrap them with a Provider component and use a Consumer component to access/manipulate the context! Simple:)
Ok, but which one should I use
We can see that our example version of Redux takes more time than the Context version. We can already see Redux:
- More lines of code are required and can be too “boilerplate” with more complex examples (multiple components to access storage).
- Added complexity: When working with many components, it may be more sensible to separate the Reducer and action types from the components into unique folders/files.
- Introducing the learning curve: Some developers find it hard to learn Redux because it requires you to learn new concepts: Reducer, dispatch, Action, thunk, middleware……
If you’re working on a more complex application and want to see a history of all your application’s Dispatch operations, “click” on any one of them and jump to that point in time, then definitely consider using Redux’s nifty Dope devTools extension!
However, if you just want to globalize some data so that it can be accessed from a bunch of components, you can see from our example that both the Redux and React Context apis do much the same thing. So in a way, you don’t have to use Redux!