React Concepts Part 2 - Redux
— React, Web Development, JSX — 8 min read
Redux
Redux is a predictable state container for JavaScript apps. It is a (Flux) state management system for cross-component or app-wide state. It helps to write applications that behave consistently, run in different environments (client, server, and native), and are easy to test. It is not React-specific library, it can be used in any JavaScript project.
There are 3 main kinds of state:
- local state - state that belongs to a single component. Example: useState for every keystroke of an input field. This should be managed component-internally with useState / useReducer
- cross-component state - state that affects multiple components. Example: open / closed state of a modal overlay. This requires "prop chains" / "prop drilling" where props (and functions) are passed across multiple components
- app-wide state - state that affects the entire app (most or all components). Example: user authentication status. This also requires "prop chains" / "prop drilling"
Both React Context and Redux solve the same problem of managing cross-component and app-wide states
Disadvantages of React Context:
- Complex Setup / Management - having several pieces of context, lots of states that affect multiple components, and multiple context providers to manage these states in a large enterprise-level application, which leads to deeply nested JSX code and/or huge Context Provider components
- Performance - React Context is useful for low frequency updates (like changing theme), but its not meant to be used for all Flux-like state propagation
Redux has exactly one central data store in our application, to save state (cross-component and app wide). Components set up subscriptions to this central store so that whenever the data changes, the store notifies the components, and the components can get the data they need (a slice of the data store) and use it. Components never directly manipulate the store data. To change the data in store, reducer functions are used, which are responsible for mutating/changing the store data. This isn't same as useReducer()
hook. Reducer functions are generic functions which take some input, transform/reduce it, and provide a new output as a result. Components dispatch actions i.e., they trigger certain actions, where actions are just simple JavaScript objects which describe the kind of operation reducers should perform. Redux then forwards these actions to reducers, reads the description of the desired operation, and this operation is performed by the reducer.
The whole global state of our app is stored in an object tree inside a single store. The only way to change the state tree is to create an action, an object describing what happened, and dispatch it to the store. To specify how state gets updated in response to an action, we can write pure reducer functions that calculate a new state based on the old state and the action. Instead of mutating the state directly, we specify the mutations you want to happen with plain objects called actions. Then we write a special function called a reducer to decide how every action transforms the entire application's state.
In a typical Redux app, there is just a single store with a single root reducing function. As your app grows, you split the root reducer into smaller reducers independently operating on the different parts of the state tree. This is exactly like how there is just one root component in a React app, but it is composed out of many small components.
This architecture might seem like a lot for a counter app, but the beauty of this pattern is how well it scales to large and complex apps. It also enables very powerful developer tools, because it is possible to trace every mutation to the action that caused it. We can record user sessions and reproduce them just by replaying every action.
Simple Example in Node.js
// to use redux in Node.js:const redux = require("redux");// for initial run, state will be undefined, hence default value is needed// initial run will not trigger the subscription of componentsconst counterReducer = (state = { counter: 0 }, action) => { if (action.type === "increment") { return { counter: state.counter + 1, }; } return state; // actions are used to perform different things to state};
const store = redux.createStore(counterReducer);
const counterSubscriber = () => { const latestState = store.getState(); // getState() gives the latest state snapshot after it was updated console.log(latestState);};
store.subscribe(counterSubscriber); // this will inform Redux that the function should be executed whenever the state changes
store.dispatch({ type: "increment" }); // this dispatches an action, where type is an identifier to distinguish different actions
createStore
requires 1 parameter - the reducer function which manipulates the store data. Data managed by this store is determined by the reducer function, as it is the one which produces new state snapshots. When this code is executed for the first time, the reducer will also be executed with a default action, which outputs an initial state. Reducer, which is a standard JS function which is called by redux, takes in 2 parameters: old/existing state and the action that was dispatched, and returns a new state object as output. It should be a pure function i.e., the same values of input should produce the same output, and there shouldn't be any side effects inside that function.
Redux for class-based components
For class-based components, connect()
can be used as wrapper around them to connect them to the store. It provides its connected component with the pieces of the data it needs from the store, and the functions it can use to dispatch actions to the store. It does not modify the component class passed to it; instead, it returns a new, connected component class that wraps the component you passed in.
connect()
returns a new function as a value, which is executed again by passing the class component inside that new function. connect()
requires 2 arguments which are functions:
mapStateToProps()
- maps Redux state to props which will be received by the class component. If a mapStateToProps function is specified, the new wrapper component will subscribe to Redux store updates. This means that any time the store is updated, mapStateToProps will be called. The results of mapStateToProps must be a plain object, which will be merged into the wrapped component’s props. If we don't want to subscribe to store updates, pass null or undefined in place of mapStateToPropsmapDispatchToProps()
- stores dispatch functions in props, so that in the class component, certain props can be executed as functions which dispatch actions to the Redux store
Redux Hooks
useSelector()
is a react-redux hook which allows us to extract data from the Redux store state, using a selector function. It is also possible to get direct access to the store itself by using useStore()
which returns a reference to the same Redux store that was passed in to the <Provider>
component. This hook should probably not be used frequently. However, this may be useful for less common scenarios that do require access to the store, such as replacing reducers. useSelector()
is more convenient than useStore as it allows to retrieve a part of the state managed by the store.
useDispatch()
returns a reference to the dispatch function from the Redux store, which can be used to dispatch actions as needed. useSelector()
requires a function which determines which piece of data needs to be extracted from the store - it is called with the entire Redux store state as its only argument. useSelector()
will also subscribe to the Redux store, and run the selector function whenever an action is dispatched
Potential problems with Redux:
- action types - we need to ensure there is no mistype of identifier used to dispatch certain actions, otherwise it won't be handled correctly by reducer
- store data - it gets bigger with the number of state variables used. This means a lot of state objects need to be copied and returned by the reducer intact for each action type - resulting in a huge reducer function
- state immutability - we need to ensure new state snapshot is returned for every action type without accidentally modifying the existing state anywhere
In order to solve these problems, Redux Toolkit can be used. The Redux Toolkit package is intended to be the standard way to write Redux logic. It was originally created to help address three common concerns about Redux:
- "Configuring a Redux store is too complicated"
- "I have to add a lot of packages to get Redux to do anything useful"
- "Redux requires too much boilerplate code"
Redux Toolkit
Redux Toolkit also includes a powerful data fetching and caching capability called "RTK Query". It is purpose-built to solve the use case of data fetching and caching, supplying a compact, but powerful toolset to define an API interface layer for your app. It is intended to simplify common cases for loading data in a web application, eliminating the need to hand-write data fetching & caching logic ourselves.
Redux Toolkit internally uses a package called Immer, which automatically detects any changes done to existing state inside reducers, and clone it to create a new state object with only modified properties changed and others as it is, in an immutable way. Redux Toolkit offers a better way to setup Redux store by using configureStore which makes merging multiple reducers easier. It is a friendly abstraction over the standard Redux createStore function that adds good defaults to the store setup for a better development experience.
Redux Toolkit has a createSlice
API that will help simplify our Redux reducer logic and actions. createSlice does several important things for us:
- We can write the case reducers as functions inside of an object, instead of having to write a switch/case statement
- The reducers will be able to write shorter immutable update logic
- All the action creators will be generated automatically based on the reducer functions we've provided
Credits and Attributions
- Official React Documentation
- React - The Complete Guide by Maximilian Schwarzmüller