Skip to main content

One post tagged with "Redux"

View All Tags

· 7 min read
William

What is Redux?

Redux is one of the most popular state management libraries in the React ecosystem, and it is also compatible with other front-end frameworks. When developing a React application without using Redux, we often encounter situations where two components share the same state. In such cases, we need to pass the state as props down to components. However, as the app grows, passing props down through more than three levels of component hierarchy can become tedious. This phenomenon is also known as "props drilling," which can lead to unnecessary re-rendering. Redux solves these problems out of the box and helps keep the app state predictable and traceable.

Concepts of Redux

Actions

Actions are plain JavaScript objects that have two fields: type and payload. The type field describes something that will happen in the app, while the payload field contains extra data related to the action.

Reducers

Reducers are pure functions that take the current state and an action as arguments and return a new state. They are responsible for updating the state based on the given action.

Store

The store holds the application state and provides methods to dispatch actions, subscribe to state changes, and access the current state.

![[Screen Shot 2024-05-02 at 4.27.23 PM.png]]

Redux Implementation

createStore

The basic createStore function accepts one parameter, the root reducer, and returns a store object which contains getState, dispatch, and subscribe methods.

function createStore(reducer) {
const getState = () => {}

const dispatch = () => {}

const subscribe = () => {};

return {
dispatch,
getState,
subscribe,
}
}
  1. The getState method needs to return the root state.
function createStore(reducer) {
let state;

const getState = () => state
}
  1. The subscribe method needs to collect observer actions and return an unsubscribe function.
function createStore(reducer) {
let listeners = new Map()
let listenerIdCounter = 0

const subscribe = (listener) => {
const listenerId = listenerIdCounter++
listeners.set(listenerId, listener)

return () => {
listeners.delete(listenerId)
};
};
}
  1. The dispatch method has an action parameter and passes it to the reducer with the current state and notifies all the observers.
const dispatch = (action) => {
state = reducer(state, action)
listeners.forEach((listener) => listener());
}
  1. After putting it all together, we need to add one more line: dispatch({type: '@@redux/INIT'}). If we didn't call dispatch, the initial state will be undefined.
function createStore(reducer) {
let state;
let listeners = new Map()
let listenerIdCounter = 0

const getState = () => state

const dispatch = (action) => {
state = reducer(state, action)
listeners.forEach((listener) => listener());
}

const subscribe = (listener) => {
const listenerId = listenerIdCounter++
listeners.set(listenerId, listener)

return () => {
listeners.delete(listenerId)
};
};

// Initialize state
dispatch({ type: '@@redux/INIT' });

return {
dispatch,
getState,
subscribe,
}
}

Now, we can try using it. We create a rootReducer and pass it into createStore to obtain the store object. We pass a listener to the subscribe method to print the state when it updates. After calling the dispatch method, the action object will be passed into the rootReducer to update the state and notify the listener we registered.

const initialState = { count: 0 };

const ACTION_TYPES = {
Increase: "Increase",
Decrease: "Decrease",
}

const rootReducer = (state = initialState, action) => {
switch (action.type) {
case ACTION_TYPES.Increase: {
const nextState = { ...state }

nextState.count++

return nextState
}
case ACTION_TYPES.Decrease: {
const nextState = { ...state }

nextState.count--

return nextState
}
default: {
return state
}
}
}

const store = createStore(rootReducer)

const unsubscribe = store.subscribe(() => {
const rootState = store.getState()

console.log("Count", rootState.count)
})


store.dispatch({ type: ACTION_TYPES.Increase })
// Count 1

store.dispatch({ type: ACTION_TYPES.Increase })
// Count 2

store.dispatch({ type: ACTION_TYPES.Decrease })
// Count 1

unsubscribe()

store.dispatch({ type: ACTION_TYPES.Increase })

However, currently, we have to put all possible actions into the same place, and we hope we can separate actions into their related independent domains and reducers. To solve this problem, Redux has another helper called combineReducers.

combineReducers

It accepts a reducers object and maps between the state keys and the reducers. The return value should fulfill the reducer signature f(state, action).

export function combineReducers(reducers){
const keys = Object.keys(reducers)

return function combinedReducer(currentRootState, action){
const nextRootState = {}
let isRootStateChange = false

for (let i = 0; i < keys.length; i++){
const key = keys[i]
const reducer = reducers[key]
const currentState = currentRootState[key]

const nextState = reducer(currentState, action)

nextRootState[key] = nextState

isRootStateChange = isRootStateChange || currentState !== nextState
}

return isRootStateChange ? nextRootState : currentRootState

}
}

Firstly, we get all the reducers' keys and return a function (combinedReducer) that fulfills the reducer signature. When the dispatch is invoked, it will call combinedReducer. The combinedReducer uses keys to iterate through all the reducers and composes all the states with the corresponding key into the nextRootState object. We use shallow equality currentState !== nextState to check if the state changed on each iteration. This is also the reason why Redux requires immutability. In the end, if the state has changed, we return nextRootState, and currentRootState otherwise.

Integrate with React

So far, we have roughly implemented the simple version of Redux. However, we still don't know how it interacts with React and how it solves props drilling and unnecessary re-rendering problems.

When using Redux in React, we need to use the Provider context and pass the store object to it. After wrapping our root component with Provider, we can start using built-in hooks such as useStore, useDispatch, and useSelector. Therefore, in the next section, we are going to implement Provider, useStore, useDispatch, and useSelector.

Provider

We use React.createContext to create Provider to wrap children and provide store.

export const StoreContext = React.createContext(null)

export const Provider = ({ store, children }) => {
return <StoreContext.Provider value={{ store }}>{children}</StoreContext.Provider>
}

useStore

We import StoreContext and use useContext to get the store object.

export const useStore = () => {
const contextValue = useContext(StoreContext)

if (!contextValue) {
throw new Error(
"could not find react-redux context value; please ensure the component is wrapped in a <Provider>"
);
}

return contextValue;
}

useDispatch

We import useStore to get the store object and return the store's dispatch method.

export const useDispatch = () => {
const {store} = useStore()

return store.dispatch
}

useSelector

The implementation of useSelector is where we decide whether to re-render the component. The concept is simple: we compare the selected state and the previous selected state and re-render the component if they are different.

  1. Use useReducer to set up forceRender by adding up the count.
  2. Get the current root state from the store and pass it to the selector function we passed to useSelector to get selectedState.
  3. Return the current selectedState.
  4. Use useRef to record the latestSelector and latestSelectedState.
  5. Use useLayoutEffect to update latestSelector and latestSelectedState.
  6. Use useLayoutEffect and subscribe to store state change. When dispatch is invoked, the updateIfChange listener will be notified.
  7. In the updateIfChange listener, we call store.getState and pass it to the selector to get newSelectedState. Then we use defaultEqualityFn to compare newSelectedState and latestSelectedState.current. If the two values are different, we update latestSelectedState.current and forceRender the component.
const defaultEqualityFn = (prevState, curState) => prevState === curState;

export const useSelector = (selector, equalityFn = defaultEqualityFn) => {
const { store } = useStore()

const [, forceRender] = useReducer(s => s + 1, 0);

const latestSelector = useRef()
const latestSelectedState = useRef();

const selectedState = selector(store.getState())

useLayoutEffect(() => {
latestSelector.current = selector
latestSelectedState.current = selectedState
})

useLayoutEffect(() => {
function updateIfChange() {
const newSelectedState = latestSelector.current(store.getState())

if (equalityFn(newSelectedState, latestSelectedState.current)) {
return
}

latestSelectedState.current = newSelectedState

forceRender()
}

const unsubscribe = store.subscribe(updateIfChange)

return () => {
unsubscribe()
}
}, [store])

return selectedState
}

Conclusion

In this article, we have implemented a simple version of Redux and integrated it with React. These basic concepts help us understand more about Redux and how it works under the hood. In the latest version, Redux has already used useSyncExternalStore hook to reimplement useSelector. If you are interested, click the link and check it out.

Reference

Why does Redux’s use of shallow equality checking require immutability?

Redux data flow

Source of createStore

Source of combineReducers

Source of react-redux hooks