As your application grows, as does its complexity. Sharing state between components quickly becomes convoluted if you don’t use an appropriate state management solution. There are an endless amount of libraries, patterns, and solutions that have popped up over the years to address state management in React and this post will cover the most popular four; Component State, Context API, and Redux.
Component State
For simple apps Component state is a great solution.
For class based components this means using setState
.
class Counter extends Component {
state = { count: 0 }
render() {
return (
<View>
<Text>{this.state.count}</Text>
<Button
onPress={() => this.setState({ count: this.state.count + 1 })}
title="+"
/>
<Button
onPress={() => this.setState({ count: this.state.count - 1 })}
title="-"
/>
</View>
)
}
}
For functional components, use useState
.
const Counter = props => {
const [count, setCount] = useState(0)
return (
<View>
<Text>{count}</Text>
<Button onPress={() => setCount(count + 1)} title="+" />
<Button onPress={() => setCount(count - 1)} title="-" />
</View>
)
}
For more complex local state management in a functional component, you can use
useReducer()
which makes it easier to handle a lot of changing state in a predicatable way.
Pros
- Simple for encapsulated state
- Native React solution
Cons
- Sharing state between components can be complicated
- Not well suited for a complex app
Context API
The Context API helps remedy the issue of “prop drilling”, passing props down through multiple levels of components to get it to where it is needed. Using the Context API, you can share values between components without having to pass props. It is recommended by the React team to use it to “share data that can be considered “global” for a tree of React components, such as the current authenticated user, theme, or preferred language”.
Using the Context API consists of three parts,
- Call
React.createContext(OptionalInitialValue)
to create a context. It returns an object with aProvider
and aConsumer
. - Use the
<Provider value={anything}>
component higher up in the tree to wrap any component trees that you want to have access tovalue
. - Use the
<Consumer>
component anywhere below theProvider
in the tree to give a component access to the value prop in theProvider
. There are two ways to use theConsumer
, “render props” style or with a hook.
// Initialize a Context
const MyContext = React.createContext({name: ''});
const MyComponent = () => (
// Wrap a part of the component tree in the Provider
<MyContext.Provider value={{name: 'Alice'}}>
<MySubComponent />
<MyOtherSubComponent />
</MyContext.Provider>
);
const MySubComponent = () => (
// Wrap a component in the Consumer to access the value prop "render props" style
<MyContext.Consumer>
{ value => (
<Text>{value.name}</Text>
)}
</MyContext.Consumer>
);
const MyOtherSubComponent = () => {
// Use the useContext hook to get the value
const value = useContext(MyContext);
return <Text>{value.name}</Text>;
};
A component calling useContext will always re-render when the context value changes. If re-rendering the component is expensive, you can optimize it by using memoization.
Pros
- Great for when you have a few pieces of global state you want to make available to a lot of components (e.g. themes, internationalization, user info).
- No prop drilling
- Native React solution
- Easy implementation, simply wrapping components in a Provider and Consumer component.
Cons
- Not the best choice when you have a lot of global state and would end up with a lot of Contexts to wrap your app in.
- The “render props” style of Consumer can introduce too much nesting and descrease readability.
- May need to consider unnecessary re-renders when using useContext and the context value changes.
Redux
Redux is probably the most popular of all the third party state management solutions. Although it gets a bad wrap as being complicated for beginners and overused (this is true in some cases), Redux is actually rather simple and definitely has its use in a bigger app. Inspired by Flux and Elm, Redux uses the concept of uni-directional data flow.
- The app has a central state
- A state change triggers UI updates
- Special functions handle state change
- User interaction triggers these special state changing functions
- Only one change happens at a time
Important Pieces
Root store
The single source of truth for the application.
import { createStore } from 'redux';
import reducer from './reducer';
const store = createStore(reducer);
Actions
Plain JavaScript objects that describe an action in your application. Usually has a type and a payload.
const action = {
type: 'LOGIN',
payload: { email: scott@dunder.com, password: 'thatswhatshesaid' },
}
It is good practice to make a file of all your action types since they will have to be used in multiple places in your application & you don’t want to risk mispelling them. Another good practice is to create action creators which you then import into your component and use.
// ./actions.js
// Action type
export const LOGIN = 'LOGIN';
// Action creator
export const login = (email, password) => {
return { type: LOGIN, payload: { email, password} };
}
import { login } from './actions';
<Button onPress(() => dispatch(login(email,password)))>
Login
</Button>
Reducers
Pure functions which take in the current state and an action, and return the new state. In order to keep the size of reducers manageable, you can split them into separate files.
import { LOGIN } from '../constants/actionTypes';
const initialState = {
user: ''
};
const rootReducer = (state = initialState, action) => {
switch (action.type) {
case LOGIN:
return { ...state, user: action.payload };
default:
return state;
}
};
export default rootReducer;
The line
{ ...state, user: action.payload }
is important to understand. It is creating a brand new state object with the whole old state, overwritten with the new user value. This adheres to the Redux principle of not modifying the state.
dispatch(action)
Dispatch is a Redux store function that is used for dispatching an action to the store.
// Dispatch an action object
dispatch({ type: INCREMENT, payload: 1 });
// Dispatch an action creator
dispatch(increment)
mapStateToProps(state, ownProps?)
This function is defined in a component that wants to access Redux state and is used to select the part of the state from the store that the component needs.
function mapStateToProps(state) {
return {
count: state.count
};
}
Optionally takes a second argument called ownProps which holds the props for the component.
mapDispatchToProps
This is an optional function that you can define for a component that wants to use the Redux store and it is used to create functions that dispatch when called and pass those functions as props to the component.
By default, a connected component has the
dispatch
function as a prop.
There are a few ways to define this mapping but the simplest is to use define it as an object.
// Import the action creators
import { increment, decrement, reset } from './actions';
const mapDispatchToProps = {
increment,
decrement,
reset
};
connect(mapStateToProps, mapDispatchToProps?, mergeProps?, options?)(Component)
This function wraps your Component and connects it to the Redux store.
// Export and use this for your Component instead of normal Component export
export default connect(mapStateToProps?, mapDispatchToProps?, mergeProps?, options?)
mergeProps and options are not commonly used.
useSelector()
The useSelector
hook allows you to extract state from the Redux store. This function essentially replaces mapStateToProps
for functional components.
export const Counter= () => {
const count = useSelector(state => state.count);
return <Text>{count}</Text>;
}
useDispatch()
The useDispatch
hook returns a reference to the dispatch
function from the Redux store. This hook replaces the need to use connect
to inject dispatch
.
export const Counter= () => {
const dispatch = useDispatch();
return (
<Button onPress=(() => dispatch(increment))>
Increment
</Button>
);
}
Main Principles
Single Source of Truth
The state of your whole application is stored in one object within a single store. This makes your state easier to work with, the application easier to reason about, and debugging less painful.
Read Only State
State cannot be modified directly, only through emitting an action describing the change. This means that Views do not directly modify state. Instead, user interactions in Views can dispatch an action that tells a function to update the state. This function replaces the old state with the updated state and the UI updates accordingly.
Reducers are Pure Functions
Reducers are simple and special functions, taking current state and an action, and returning a new, updated state.
The state is replaced not mutated
Reducers are also pure functions, meaning they always return the same output for the same set of inputs.
// Impure
const impure = function(n) {
return Math.random() * n;
}
impure(3) // 1.2
impure(3) // 1.8
impure(3) // 0.9
// Pure
const pure = function(a) {
return 1 + a;
}
pure(3) // 4
pure(3) // 4
pure(3) // 4
As you can see, the first function is impure, if you were to input the same value for n
multiple times, you would get varying results do to the Math.random()
. The second function on the other hand, is pure since it will always be the input + 1.
Using Redux
yarn add redux react-redux
Under the hood, Redux utilizes the Context API covered earlier, so it requires wrapping your application in a Provider
and passing in the root store
.
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import rootReducer from './reducers';
const store = createStore(rootReducer)
const App = () => (
<Provider store={store}>
<App />
</Provider>
);
Then, there are two ways to access the state from the store.
import { connect } from 'react-redux';
import { increment } from '../actions';
class Counter extends Component {
increment = () => {
// Dispatch increment action
this.props.increment();
}
render() {
return (
<View>
// Access count variable that is available from props
<Text>{this.props.count}</Text>
<Button onPress={this.increment}>+</Button>
</View>
)
}
}
// This function is used to pullout the state needed by this
// component and mapping it to the component's props
// (e.g. count -> this.props.count)
function mapStateToProps(state) {
return {
count: state.count
};
}
// An object of action creators to add to the props
const mapDispatchToProps = {
increment,
};
// Wrapping your component in connect puts the results of
// mapStateToProps, mapDispatchToProps and the dispatch
// function into the components props
export default connect(mapStateToProps, mapDispatchToProps)(Counter);
The newer way utilizes hooks so it can only be done in functional components.
import { useSelector, useDispatch } from 'react-redux';
import { increment } from '../actions';
const App = props => {
// Get counter from the state store
const counter = useSelector(state => state.counter);
// Get the dispatch function
const dispatch = useDispatch();
return (
<View>
{ counter }
<Button onPress={ () => dispatch(increment) }>
increase
</Button>
</View>
);
}
Pros
- Methodical and well structured state management
- Easily testable/
- Enables hot reloading
- Powerful debugging; Time travel debugging
Cons
- Slight learning curve
- Lots of boilerplate code
- Requires middleware to better handle advanced cases like async
Conclusion
It may seem like a daunting task to choose how to manage your state in your application but I suggest you start with local state and switch some of it to Context API as you find it is required in multiple places. If you find yourself with too many Contexts then you should consider moving just your global state over to Redux.
Top comments (0)