Table of Contents
- What is Redux?
- State Trees
- Actions
- The Store
- Interacting with UI
- Reducer Composition
- Middleware
- Selectors
- Asynchronous Redux
- Thunks
- Context
- Redux Hooks
- Folder Structure
- Useful Redux Packages
What is Redux?
- Helps us make state more predictable, decreasing the amount of bugs that we have.
- It is a predictable state container for JS apps.
- It manages state in a single immutable store.
- It's goal is to make state changes predictable and manageable through actions and reducers.
- Enforces unidirectional data flow.
State Trees
- Rather than having our state scattered across our application, we will have it in a single location, a state tree.
- Benefits:
- Shared cache: We don't have to pull state up anymore, any component that needs state just needs access to the tree.
- Predictable state changes: This is achieved by setting strict rules on the tree.
- Improved development tooling: Parts of the application can be reloaded without throwing away our state.
- Pure functions: The app just receives state and displays UI based on that state.
- Server rendering: The idea of server rendering is to spit back the state of the application along with the markup when the initial request comes in. This is easier when all of our state is in a single location.
- Interacting with our State Tree:
- Get our state
- Update our state
- Listen to changes on our state
- All of the above interactions along with the state tree itself can be referred to as the store.
Actions
- Are payloads of information that send an instruction on what type of transformation to make to your application's state as well as any other relevant information.
- It's nice to have our actions saved as constant variables at the top of our code so we are not comparing strings everywhere.
- In other terms, it's an object which describes the transformation you want to make to your state.
const action = { type: "LIKE_TWEET", // REQUIRED!!! Other properties are up to you. id: 950788310443724800, uid: "tylermcginnis", };
The Store
// LIBRARY CODE
function createStore(reducer) {
// 1. state - hidden
// 2. get the state - public
// 3. listen for changes on the state - public
// 4. update thee state - public
let state;
let listeners = [];
const getState = () => state;
const subscribe = (listener) => {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
};
const dispatch = (action) => {
state = reducer(state, action);
listeners.forEach((listener) => listener());
};
return {
getState,
subscribe,
dispatch,
};
}
// APP CODE
// Actions
const ADD_TODO = 'ADD_TODO';
const REMOVE_TODO = 'REMOVE_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';
const ADD_GOAL = 'ADD_GOAL';
const REMOVE_GOAL = 'REMOVE_GOAL';
// Action Creators
function addToDoAction(todo) {
return {
type: ADD_TODO,
todo,
}
}
function removeToDoAction(id) {
return {
type: REMOVE_TODO,
id,
}
}
function toggleToDoAction(id) {
return {
type: TOGGLE_TODO,
id,
}
}
function addGoalAction(goal) {
return {
type: ADD_GOAL,
goal,
}
}
function removeGoalAction(id) {
return {
type: REMOVE_GOAL,
id,
}
}
// Reducer Function for Todos
function todosReducer(state = [], action) {
switch (action.type) {
case ADD_TODO:
return state.concat([action.todo]);
case REMOVE_TODO:
return state.filter((todo) => todo.id !== action.id);
case TOGGLE_TODO:
return state.map((todo) =>
todo.id !== action.id
? todo
: Object.assign({}, todo, { complete: !todo.complete })
);
default:
return state;
} {
return {
type: REMOVE_GOAL,
id,
}
}
}
// Reducer Function for Goals
function goalsReducer(state = [], action) {
switch (action.type) {
case ADD_GOAL:
return state.concat([action.goal]);
case REMOVE_GOAL:
return state.filter((goal) => goal.id !== action.id);
default:
return state;
}
}
// Main Reducer Function for our app that combines our reducers.
function appReducer(state = {}, action) {
return {
todos: todosReducer(state.todos, action),
goals: goalsReducer(state.goals, action)
}
}
const store = createStore(appReducer);
const unsubscribe1 = store.subscribe(() => {
console.log("The new state is: ", store.getState());
});
store.dispatch(addTodoAction({
id: 0,
name: 'Walk the dog',
complete: false,
}))
store.dispatch(addTodoAction({
id: 1,
name: 'Wash the car',
complete: false,
}))
store.dispatch(addTodoAction({
id: 2,
name: 'Go to the gym',
complete: true,
}))
store.dispatch(removeTodoAction(1))
store.dispatch(toggleTodoAction(0))
store.dispatch(addGoalAction({
id: 0,
name: 'Learn Redux'
}))
store.dispatch(addGoalAction({
id: 1,
name: 'Lose 20 pounds'
}))
store.dispatch(removeGoalAction(0))
unsubscribe1(); // Will unsubscribe that particular function from the listeners array
{
return {
type: REMOVE_GOAL,
id,
}
}
Interacting with UI
-
We can interact with our UI / HTML elements via getting them with
document.getElementById()
-
We then do what we want with that element in our subscribe function.
-
This allows the appropriate things to happen when we run a dispatch call.
-
Below is an example of how this can be done with adding items to a list.
store.subscribe(() => { const { todos } = store.getState() document.getElementById('todos').innerHTML = '' todos.forEach(addTodoToDOM) }) 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)) }) document.getElementById('todos') .appendChild(node) }
Reducer Composition
combineReducers()
: invokes other reducers when we want to combine reducers for our state tree.- We should have a reducer for each layer of nesting in our object.
Redux.createStore(Redux.combineReducers({
reducer1,
reducer2
}))
Middleware
- Essentially provides a third party extension point between dispatching an action and the moment it reaches the reducer.
- We would want to do this if we want to set our own rules and logic around our dispatches.
const store = Redux.createStore(Redux.combineReducers({
todos,
goals,
}), Redux.applyMiddleware(checker))
const checker = (store) => (next) => (action) => {
// middleware code
return next(action) // We call next in case there are multiple middleware functions
}
Selectors
- A selector function is one that accepts the Redux store state (or a part of it) as a argument, and returns data based on that state.
- Recommended to prefix selector functions with the word
select
along with a description of what is being selected. - The
useSelector
hook takes a selector function as an argument. - Reasons to use Selectors:
- Encapsulation and Reusability: allows us to reuse our selector everywhere and be able to change it without it breaking or having to be changed everywhere in the codebase.
- We need to utilise memoization to avoid recalculating results if the same inputs are passed in.
- Memoization is a form of caching, it tracks inputs to a function, and stores the inputs and its results for later reference.
createSelector
is a function that creates memoized selectors, it takes in input selectors and an output selector (which does the transformations) as arguments.- It only memoizes the most recent set of parameters.
const a = someSelector(state, 1) // first call, not memoized const b = someSelector(state, 1) // same inputs, memoized const c = someSelector(state, 2) // different inputs, not memoized const d = someSelector(state, 1) // different inputs from last time, not memoized
Asynchronous Redux
- Usually we aren't dealing with things locally, usually we are interacting with a database or something external.
- This means that we will need to be making API requests and will need to wait for these requests to finish.
- We can deal with this by using all of our Promise and async/await knowledge.
- An example is shown below where we want to delete an item from the database and once we know that it is deleted, we want to update our local state.
- We are using a technique called optimistic updates: avoids UI delay, we eagerly remove the item locally then if the request fails we just add it back in.
removeItem = (todo) => { this.props.store.dispatch(removeTodoAction(todo.id)) return API.deleteTodo(todo.id) // If the delete is unsuccessful add the item back in locally // Doing it this way around removes the small delay for the user when they click the remove button. .catch(() => { this.props.store.dispatch(addTodoAction(todo)); alert("An error occurred. Try again.") }) }
- we can separate our UI logic from our Asynchronous logic by abstracting this away into an action handler, see this more in thunks.
Thunks
-
Three Stages to an asynchronous request:
- Start of the request
- If the request succeeds
- If the request fails
-
The initial state of a reducer may then look like:
const initialState = { data: [], isFetching: false, error: "", };
-
The reducer that corresponds with this initial state might look like:
function reducer(state = initialState, action) { switch (action.type) { case "FETCHING_DATA": return { ...state, isFetching: true, }; case "FETCHING_DATA_ERROR": return { ...state, isFetching: false, error: action.error, }; case "FETCHING_DATA_SUCCESS": return { ...state, isFetching: false, error: null, data: action.data, }; } }
-
Our action creators that deal with the different states will then look like:
function fetchingData() { return { type: "FETCHING_DATA", }; } function fetchingDataError(error) { return { type: "FETCHING_DATA_ERROR", error: error.msg, }; } function fetchingDataSuccess(data) { return { type: "FETCHING_DATA_SUCCESS", data, }; }
Context
-
We can create a context, which is referred to as the Provider.
const { Consumer, Provider } = React.createContext("defaultValue");
- The provider can take a value which will make the value available to anyone subscribed to that context.
-
The Consumer can get access to this value by subscribing to the context and then use the value via a function call.
<LocaleContext.consumer> {(locale) => <Posts locale={locale} />} </LocaleContext.consumer>
-
You can pass a new value to a nested provider and all children will refer to that provided value since children get their value from the nearest parent.
-
We can connect existing components to context by creating an additional component for each component that acts as a wrapper of sorts to connect that component to the context.
Redux Hooks
-
useSelector()
: Grabs an item from the store.- For getting multiple items, don't use objects as this will cause unnecessary re-renders.
- Instead do separate calls, they will be batched together nicely due to the batching behavior in Redux v7.
// NO! const { user, authed, notifications } = useSelector((state) => ({ user: state.user, authed: state.authed, notifications: state.notifications, })); // YES! 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() ... }
Folder Structure
- We can split our actions, components, middleware and reducers into separate files for organisation.
- It is important that each one of those that has a method that combines all of them, like say
applyMiddleware
orcombineReducers
, we give that folder an index.js file and default export the necessary function.
Useful Redux Packages
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 automatically generating tests for your reducers based on the actions in your app