Redux
The Store
An app is composed primarily of UI and state. By increasing the predictability of your state you decrease the presence of bugs in your application.
- A State Tree is a data structure that enables storage of application state in one place and manages attempts to update and access that state. Benefits of state trees include:
- Shared cache: Components can share state regardless of their location in the application
- Predictable state changes: Establishing strict rules for who can update and access state naturally increases the predictability of state in an application
- Improved developer tooling: Enables developers to explicitly set state, making debugging and testing much easier
- Pure functions: By having all of our state in one place the rest of the app is just receiving state and displaying UI based on that state
- Server-side Rendering: Sending state and markup back as a response is easier if all your state is in one locations
- A store consists of a state tree, a way to get the state, a way to update the state and a way to listen for changes in the state
- Create store with
configureStore({ reducer: rootReducer})
, where rootReducer is a function that returns the next state tree, given the current state tree and an action to handle - Get state with
getState
returns the current state tree - Listen for changes with
subscribe(listener)
: Pass in a listener callback. Added to listener array, which is called whenever state is updated. Returns callback function that will unsubscribe the listener when called - Update state with
dispatch(action)
- State transformations events are represented as 'actions', objects which describe what sort of transformation you want to make to your state as well as any other relevant information.
dispatch(action)
will call the store's reducer function with the current state and theaction
function and use the result as the new state tree and then notify every attached listener. Returns the dispatched action.- It is standard practice to implement 'action creators' that will construct action objects rather than just create the whole action object including the type every time.
- Reducers for different parts/slices of the state tree can be combined into one root reducer:
const store = configureStore({
reducer: {
todos: todoReducer,
goals: goalReducer
}
})
UI
- Attaching store-interfacing behaviours to UI elements example:
function addTodo() {
function generateId() {
return Math.random().toString(36).substring(2) + (new Date()).getTime().toString(36)
}
const input = document.getElementById('todo');
const name = input.value ? ;
input.value = ''; // Reset input field
store.dispatch(addTodoAction({ // where addTodoAction is an action creator
id: generateId(),
name,
complete: false,
}))
}
document.getElementById('totoBtn').addEventListener('click', addTodo);
function.addTodoToDOM(todo) {
const node = document.createElement('li');
const text = document.createTextNode(todo.name);
node.appendChild(text);
node.style.textDecoration = todo.complete ? 'line-through' : 'none';
node.addEventListener('click', () => {
store.dispatch(toggleTodoAction(todo.id)) // where toggleTodoAction is an action creator
})
document.getElementById('todo').appendChild(node);
}
store.subscribe(() => {
const todos = store.getState();
document.getElementById('todos').innerHTML = '';
todos.forEach(addTodoToDOM);
})
Middleware
- Middleware, in Redux, provides a third-party extension point between dispatching an action and the moment it reaches the reducer. Usage:
import { configureStore } from
const middleware = (store) => (next) => (action) => {
// ...
const result = next(action)
// ...do something with result, e.g. log it
return result // pass result forward to reducers
}
const store = confitureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(middleware),
})
Redux with React
- A Redux store can be passed into the component tree as a prop or via a Provider.
Async Redux
- A 'loading' slice may be added to the store to provide an interface to check if the store is still awaiting asynchronous data. Add a reducer that checks the type of action performed. If the action is known to be async it should return false, and return true in other cases. This is useful for conditional rendering in React apps using Redux.
- Rendered state can be updated 'optimistically'. The following example implements a delete event handler that deletes an item from the store before re-adding it if performing the same operation on an API failed. The async logic is separated from the component logic by encapsulating it into a thunk action creator (enabled by a thunk middleware function).
function handleDeleteTodo(todo) {
return (dispatch) => {
dispatch(removeTodoAction(todo.id))
return API.deleteTodo(todo.id) // attempt to remove from API
.catch(() => { // If API call threw an exception, add back to the store and display an error
dispatch(addTodoAction(todo));
alert('An error occured. Try again.');
})
}
}
const thunk = (store) => (next) => (action) { // Middleware that ensures functions passed to dispatch will be invoked, passing in store's dispatch method so the functions can update the store
if (typeof action === 'function') {
return action(store.dispatch);
}
return next(action);
}
/** In your component... **/
removeItem = (todo => {
this.props.store.dispatch(handleDeleteTodo(todo))
})
- Thunkified action creators must return a function that accepts
dispatch
as an argument - The best UIs account for the three possible stages of async requests (start, success, failure) and inform the user about each stage.
- Other options for async redux operations include redux-promise and redux-saga.
react-redux
(Sourced from react-redux.js.org. ui.dev discusses the connect API but the React-Redux hooks API used in the below example is now recommended as the default. This section also demonstrates how to create slices using the createSlice
method in the Redux toolkit.)
/***** INDEX.JSX *****/
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import App from './App';
import counterReducer from '../features/counter/counterSlice';
const store = configureStore({
reducer: {
counter: counterReducer,
}
})
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<Provider store={store}> // from react-redux
<App />
</Provider>,
)
/***** COUNTERSLICE.JS *****/
import { createSlice } from '@reduxjs/toolkit';
export const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0,
},
reducers: {
increment: (state) => {
state.value += 1;
},
},
});
export const { increment } = counterSlice.actions;
export default counterSlice.reducer;
/***** COUNTER.JSX *****/
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment } from './counterSlice';
export function Counter() {
const count = useSelector((state) => state.counter.value); // useSelector hook allows reading from the store
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(increment())}>
Increment
</button>
)
}
Folder structures
- Rails-style: Separate folders for actions, constants, reducers, containers and components.
- Feature/Domain-style: Separate folders per feature or domain, possibly with sub-folders per file type
- Ducks/Slices: Like domain style, but explicitly ties together actions and reducers often by defining them in the same file