What is the Store?
-
An application is composed primarily of UI and State
- Often, a common cause for bugs is state mismanagement
- Increasing the predictability of state can decrease the amount of bugs
-
A state tree is a single location which stores all our state in an application
- Increases the predictability of state changes, define clear rules for how to access and update that state
- State becomes global, it can be shared to other components regardless of their location
- Improved developer tooling, other parts of the application can be reloaded without throwing away our state
- Improves server-side rendering, easier to send state and return markup as a response
- Relating to the idea of pure functions, by having all our state in one location, the rest of the application is simply receiving state and displaying UI based on that state
-
The Store consists of the state tree and a way to retrieve it, updatee it and listen for changes
-
To create a store, call configureStore({ reducer: rootReducer }) where rootReducer is a reducer function. Can also use createStore
- rootReducer takes in two parameters, the current state and an action event to handle
- It will return a new state as a result of the action event specified
function postsReducer(state = [], action) { if (action.type === "UPVOTE") { return ... // New updated state } else if { ... } else { return state; } }
- We can also have multiple reducers to handle different types of tasks, the reducer property becomes an object of different reducers
const store = configureStore({ reducer: { posts: postsReducer, friends: friendsReducer } })
- The state management object will contain different types of state as a result that will be updated through the rootReducer
- The object of reducers will automatically combine to create the rootReducer in Redux through the combineReducers utility
function rootReducer(state = {}, action) { return { posts: postsReducer(state.posts, action), friends: friendsReducer(state.friends, action) }; }
-
Calling getState will return the current state tree
- State can be updated by using dispatch(action)
- State transformation events are represented as actions, an object which stores the type of action, and any additional information needed to undertake that action
const action = { type: "UPVOTE", id: 950788310443724800, uid: "Bob", };
- Calling dispatch(action) invokes the reducer function passed to the store with the current state and action
- Once the reducer returns the updated state, every attached listener will be notified of this change
-
subscribe(listener) allows us to listen for state changes
- It takes in a listener callback function then our callback will be added to an array of listeners
- A cleanup function is returned and invoked whenever the listener needs to be unsubscribed
-
User Interface
-
Using JavaScript and HTML
<!DOCTYPE html> <html> <head> <title>TODOs</title> </head> <body> <div> // UI </div> <script type="text/javascript"> // JavaScript code </script> </body> </html>
-
Can add/remove UI elements when updating the store, the example below adds a <li> element when a new TODO item is added
<div> <h1>Todo List</h1> <input id='todo' type='text' placeholder="Add Todo"> <button id='todoBtn'>Add Todo</button> <ul id='todosUL'></ul> </div> <script type="text/javascript"> // Library store code // Action functions // TODO Reducer const store = createStore(todoReducer); // Use combineReducers({ ... }) if there are multiple reducers store.subscribe(() => { const { todos } = store.getState(); document.getElementById('todosUL').innerHTML = ''; todos.forEach(addTodoToDOM); // Whenever a new TODO is added, add a list element to the DOM to display it }); // DOM code function addTodo () { const newId = ... // Some way to generate a unique ID const input = document.getElementById('todo'); const name = input.value; input.value = ''; store.dispatch(addTodoAction({ id: newId, name, complete: false, })); } function addTodoToDOM(todo) { const node = document.createElement('li'); // Display the TODO item and create a button to remove it const text = document.createTextNode(todo.name); const removeBtn = document.createElement('button'); removeBtn.innerHTML = 'X'; removeBtn.addEventListener('click', () => { store.dispatch(removeTodoAction(todo.id)); }); node.appendChild(text); node.appendChild(removeBtn); node.style.textDecoration = todo.complete ? 'line-through' : 'none'; // Add strikethrough text styling if TODO is completed node.addEventListener('click', () => { // If the TODO is clicked, toggle its completion status store.dispatch(toggleTodoAction(todo.id)); }); document.getElementById('todosUL').appendChild(node); // Add the new list element to the unordered list element } document.getElementById('todoBtn').addEventListener('click', addTodo); </script>
Middleware
-
If you want to customise the dispatch to do some preliminary processing, you can create an intermediary function
- With Redux, middleware provides a third-party extension point between dispatching an action and the moment it reaches the reducer
function middleware(store) { return function(next) { return function(action) { // Preliminary action, data preprocessing, etc. ... const result = next(action); // Perform task on result, logging, etc. ... return result; // Pass to reducer } } }
const middleware = (store) => (next) => (action) => { // ES6 syntax // Preliminary action, data preprocessing, etc. ... const result = next(action); // Perform task on result, logging, etc. ... return result; // Pass to reducer } const store = createStore(todoReducer, applyMiddleware(middleware)); // applyMiddleware is a Redux function // It takes in an arbitrary amount of middleware functions
-
Some popular packages in the Redux ecosystem implemented via middleware
- redux-api-middleware: Redux middleware for calling an API
- redux-logger: Logger middleware for Redux
- redux-promise-middleware: Redux middleware for resolving and rejecting promises with conditional optimistic updates
- redux-thunk: Thunk middleware for Redux
- redux-logic: Redux middleware for organizing business logic and action side effects
- redux-observable: RxJS middleware for action side effects in Redux using "Epics"
- redux-test-recorder: Redux middleware to automatically generate tests for reducers through ui interaction
- redux-reporter: Report actions & metadata to 3rd party providers, extremely useful for analytics and error handling (New Relic, Sentry, Adobe DTM, Keen, etc.)
- redux-localstorage: Store enhancer that syncs (a subset) of your Redux store state to localStorage
- redux-node-logger: A Redux Logger for Node Environments
- redux-catch: Error catcher middleware
- redux-cookies-middleware: A Redux middleware which syncs a subset of your Redux store state with cookies
- redux-test-recorder: Redux test recorder is a Redux middleware + included component for automagically generating tests for your reducers based on the actions in your app
Redux with React
- Instead of regular JavaScript and HTML, Redux can be used with React as well
- For components in an application to gain access to the store, it can be passed within the component tree through props
- Use lifecycle methods to subscribe to listeners
componentDidMount() { const { store } = this.props; store.subscribe(() => this.forceUpdate()); // Forces a re-render just like this.setState, typically not used because it's an anti-pattern }
Async Redux
-
Side effects such as using APIs to fetch the initial data can be done before subscribing and forcing a re-render
componentDidMount() { const { store } = this.props; Promise.all([API.fetchTodos()]) // If you want to render initial TODO data from some API .then(([todos]) => { store.dispatch(someActionFunctionToStoreReceivedData(todos)); // Add TODOs to the state managing its list }); store.subscribe(() => this.forceUpdate()); // Forces a re-render just like this.setState, typically not used because it's an anti-pattern }
- Can also create a reducer called loading for example, to determine if the UI should display yet if the data is not fetched
function loading(state = true, action) { switch(action.type) { case RECEIVE_DATA : return false; default : return state; } } ... render() { const { todos, loading } = this.props.store.getState(); if(loading === true) { return <h3>Loading</h3> } return ... }
-
A loading slice can be added to the store, which provides an interface to check if there is any awaiting asynchronous data
- Can create another reducer that checks if an action is asynchronous. If it's asynchronous, return false, otherwise, return true
- This is a form of implementing conditional rendering using Redux
-
The UI can be rendered optimistically, which means it displays anticipated changes without waiting for confirmation from the server
- In the case of errors, a rollback will occur to display the previous state
- We can separate the asynchronous logic from the component logic through encapsulation using thunks
- The thunkified action creators return a function with dispatch as a parameter
- The following example has an event handler to delete items before adding it back if our API request fails
function handleRemoveTodo(todo) { // Separated asynchronous logic into its own function return (dispatch) => { dispatch(removeTodoAction(todo.id)); return API.deleteTodo(todo.id).catch(() => { dispatch(addTodoAction(todo)); alert('An error occurred. Try again.'); }); } } const thunk = (store) => (next) => (action) { // Middleware, passing in store's dispatch function to allow action creators to update the store if (typeof action === "function") { // Remember to add to applyMiddleware return action(store.dispatch); } return next(action); } // React const handleRemoveItem = (todo) => ({ this.props.store.dispatch(handleRemoveTodo(todo)) }); // No more asynchronous logic
- Redux has its own thunk middleware
const store = createStore( ... , applyMiddleware(ReduxThunk.default, ... ));
-
There are usually three stages of an asynchronous request. First is the start of the request, second is if the request succeeds, and third is if the request fails
- The best UIs account for and inform the user about each stage
const initialState = { data: [], isFetching: false, error: "", };
-
Other middleware for asynchronous options
- Redux Promise: FSA-compliant promise middleware for Redux
- Redux Saga: An alternative side effect model for Redux apps
react-redux
-
The store is being passed around to components through props which is unrealistic in larger applications
-
Whole application is re-rendered whenever a force update occurs from the subscribe listener
-
The following example extracts some of these issues
- Context provides a way to pass data through a component tree without having to pass props manually
- Use createContext for each unique piece of data that needs to be available
- Provider allows us to declare the data we want available
- Consumer allows any component in the tree that needs the data to be able to subscribe to it
const Context = React.createContext(); class Provider extends React.Component { render() { return ( <Context.Provider value={this.props.store}> {this.props.children} </Context.Provider> ) } } const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <Provider store={store}> <App /> </Provider> ); class TODO extends React.Component { ... render() { return ( <Context.Consumer> {(store) => { // Any logic that needs the store ... }} </Context.Consumer> ) } }
- However, componentDidMount uses the store but can't access it from the render method scope
- Create a connected component to work around this problem
// Provider class ... class ConnectedApp extends React.Component { render() { return ( <Context.Consumer> {(store) => ( <App store={store} /> )} </Context.Consumer> ) } } const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <Provider store={store}> <ConnectedApp /> </Provider> );
- Similar to ConnectedApp, for all child components that need store, create another class that consumes the context and put any necessary logic within that scope
class ConnectedTODO extends React.Component { render() { return ( <Context.Consumer> {(store) => { const { todos } = store.getState() return <TODO todos={todos} dispatch={store.dispatch} /> }} </Context.Consumer> ) } } class App extends React.Component { ... render() { return ( <div> <ConnectedTODO /> </div> ) } }
- Context provides a way to pass data through a component tree without having to pass props manually
-
We can refactor the code before by creating another layer of abstraction, we want to handle all the context sharing under the hood
- Create a library to implement a function that returns a Component that needs to be rendered
const ConnectedApp = connect((state) => ({ loading: state.loading }))(App); // Library code const Context = React.createContext(); function connect(mapStateToProps) { return (Component) => { class Receiver extends React.Component { componentDidMount() { const { subscribe } = this.props.store; this.unsubscribe = subscribe(() => this.forceUpdate()); } componentWillUnmount() { this.unsubscribe(); } render() { const { dispatch, getState } = store; const state = getState(); const stateNeeded = mapStateToProps(state); return <Component {...stateNeeded} dispatch={dispatch} /> } } class ConnectedComponent extends React.Component { render() { return ( <Context.Consumer> {(store) => <Receiver store={store} />} </Context.Consumer> ) } } return ConnectedComponent; } }
-
The recommended way is to use the react-redux library
- Don't need the library code from before, the createContext invocation or the Provider class anymore
- Instead of creating our own context, use the in-built context from react-redux
const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <ReactRedux.Provider store={store}> <ConnectedApp /> </ReactRedux.Provider> )
- Anywhere that used the connect function we created before, can use ReactRedux.connect instead
const ConnectedApp = ReactRedux.connect((state) => ({ loading: state.loading }))(App); const ConnectedTODO = ReactRedux.connect((state) => ({ todos: state.todos }))(TODO);
Folder Structure
-
Create separate folders for actions, reducers, middleware and components
my-react-project public src actions ... reducers ... middleware ... components ... index.js .gitignore package-lock.json package.json README.md
- Within those folders, create separate files for functions relating to handling specific components
- Can create an index.js file for the reducers and middleware folder if applicable
- Import the reducers/middleware from the other files
- Use combineReducers or applyMiddleware and export it
- To create the store, use the main index.js file
import * as React from "react"; import * as ReactDOM from "react-dom"; import App from "./components/App"; import reducer from "./reducers"; import middleware from "./middleware"; import { Provider } from "react-redux"; import { createStore } from "redux"; const store = createStore(reducer, middleware); const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <Provider store={store}> <ConnectedApp /> </Provider> );
-
Other folder structures
-
Ducks - Redux Reducer Bundles
- Have a module file which represents a single reducer along with any action creators/constants associated with it
-
Domain based folders
- Group actions, reducers, etc. by the domain they care about
- Rather than separating them out into generic actions and reducers folders
-
Hooks
-
The Higher-order component, connect is a convoluted pattern used before functional components and hooks existed
- Used to have to rely on the connect component to connect a component to the Redux store
- react-redux v7.1.0 can now break out that logic into separate custom hooks
-
useSelector allows you to extract data from the Redux store using a selector function
import { useSelector } from "react-redux"; function Profile () { const user = useSelector((state) => state.user); ... }
- To get multiple values from the store, use separate calls to take advantage of batching, which causes only a single render to occur
- Trying to obtain all the values through object destructuring is not as performant, useSelector uses strict reference equality to decide when re-renders occur
const user = useSelector((state) => state.user); const authed = useSelector((state) => state.authed); const notifications = useSelector((state) => state.notifications);
-
useDispatch returns a reference to the store's dispatch function
import { useDispatch } from "react-redux"; function Profile () { const dispatch = useDispatch(); ... }
-
Referencing the code examples used so far, here are some code snippets to demonstrate how refactoring would occur
export default function App() { const dispatch = useDispatch(); const loading = useSelector((state) => state.loading); React.useEffect(() => { dispatch(handleInitialData()); // Function to fetch initial data from an API }, [dispatch]); return ... }
export default function TODO() { const inputRef = React.useRef(""null""); const dispatch = useDispatch(); const todos = useSelector((state) => state.todos); const addItem = (e) => { e.preventDefault(); dispatch(handleAddTodo( inputRef.current.value, () => inputRef.current.value = ""; )); }; const toggleItem = (id) => { dispatch(handleToggle(id)); } const removeItem = (todo) => { distpatch(handleRemoveTodo(todo)); }; return ( <div> <h1>Todo List</h1> <input type="text" placeholder="Add TODO" ref={inputRef} /> <button onClick={addItem}>Add TODO</button> <List toggle={toggleItem} items={todos} remove={removeItem} /> </div> ); }