See presentation on React advanced
Browser plugin for Firefox / Chrome:
https://github.com/zalmoxisus/redux-devtools-extension
View Redux state via:
browser-devtools (F12) → Redux → State → Chart/Tree
websites that use Redux (we can inspect the Redux state):
functionality:
Redux toolkit enables a simplified setup of Redux and associated libraries (in the spirit of create-react-app)
We will use it throughout this presentation
npm package: @reduxjs/toolkit
functionality (see what's included):
In Redux: application state is stored globally.
The state is stored independent from React components.
There is one store that contains all data.
A store may be composed of different parts.
Characteristics of Redux reducers (compared to the reducer hook):
the initial state is passed in as a default parameter:
const initialState = []
const todosReducer = (oldState = initialState, action) => {...}
unknown actions should leave the state unchanged:
default:
return oldState;
const initialState = [];
const todosReducer = (oldState = initialState, action) => {
switch (action.type) {
case 'addTodo':
return [
...oldState,
{
title: action.payload,
completed: false,
id: Math.max(0, ...oldState.map((t) => t.id)) + 1,
},
];
case 'deleteTodo':
return oldState.filter(
(todo) => todo.id !== action.payload
);
default:
return oldState;
}
};
import { Action, PayloadAction } from '@reduxjs/toolkit';
export type TodosAction =
| PayloadAction<string, 'todos/addTodo'>
| PayloadAction<number, 'todos/toggleTodo'>
| Action<'todos/deleteCompletedTodos'>;
export type TodosState = Array<Todo>;
const todosReducer = (
state: TodosState,
action: TodosAction
): TodosState => {
// ...
};
import todosReducer, {
TodosState,
TodosAction,
} from './todosReducer';
or:
import todosReducer from './todosReducer';
type TodosAction = Parameters<typeof todosReducer>[1];
type TodosState = ReturnType<typeof todosReducer>;
creating a Redux store that will contain the state; the store is managed by a reducer
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './state/todos';
const todosStore = configureStore({
reducer: todosReducer,
});
Directly using the store:
console.log(todosStore.getState());
todosStore.dispatch({
type: 'addTodo',
payload: 'learn Redux',
});
console.log(todosStore.getState());
npm packages:
react-redux
@types/react-redux
Provider: is used to add a Redux store to a React app
All sub-components of the provider can access the store
// index.js
import { Provider } from 'react-redux';
[...]
ReactDOM.render(
<Provider store={myStore}>
<App/>
</Provider>
...
);
By using useSelector
we can query the state of the Redux store.
We pass a so-called selector function to useSelector
.
The selector function receives the entire Redux state and returns a value that is derived from the state.
import { useSelector } from 'react-redux';
const TodoList = () => {
const todos = useSelector((state) => state);
const numTodos = useSelector((state) => state.length);
const numCompletedTodos = useSelector(
(state) => state.filter((todo) => todo.completed).length
);
// ...
};
getting the state type:
// rootReducer.ts
export type State = Array<Todo>;
or
// store.ts
export type State = ReturnType<typeof todosStore.getState>;
using with useSelector
:
useSelector((state: State) => state.length);
By using useDispatch
we can access the dispatch
function of the Redux store and use it to dispatch actions.
import { useDispatch } from 'react-redux';
const TodoList = () => {
const dispatch = useDispatch();
...
dispatch({ type: 'deleteCompletedTodos' });
};
import { Dispatch } from '@reduxjs/toolkit';
const dispatch = useDispatch<Dispatch<TodoAppAction>>();
distinction that can be useful:
presentational components:
container components:
example:
generic React TodoList
component with these props/events:
todos
onToggle
onDelete
TodoListContainer
component which connects the TodoList
component with the Redux store:
TodoList
receive values from the Redux store's stateTodoList
trigger actions in the Redux storemanual connection:
const TodoListContainer = () => {
const todos = useSelector((state) => state);
const dispatch = useDispatch();
return (
<TodoList
todos={todos}
onToggle={(id) =>
dispatch({ type: 'toggle', payload: id })
}
onDelete={(id) =>
dispatch({ type: 'delete', payload: id })
}
/>
);
};
The connect
function:
import { connect } from 'react-redux';
const TodoListContainer = connect(
(state) => ({ todos: state }),
(dispatch) => ({
onToggle: (id) =>
dispatch({ type: 'toggle', payload: id }),
onDelete: (id) =>
dispatch({ type: 'delete', payload: id }),
})
)(TodoList);
The connect
function:
connect
receives two functions; these functions are often defined separately under the names mapStateToProps
and mapDispatchToProps
:
import { connect } from 'react-redux';
const ComponentContainer = connect(
mapStateToProps,
mapDispatchToProps
)(Component);
We connect a simple NumberInput
component to the Redux store
const NumberInput = ({
value,
onIncrement,
onDecrement,
}) => (
<div>
<button onClick={onDecrement}>-</button>
{value}
<button onClick={onIncrement}>+</button>
</div>
);
interface of the component:
value
onIncrement
onDecrement
interface of the Redux store:
fontSize
increaseFontSize
reduceFontSize
import { useSelector, useDispatch } from 'react-redux';
const FontSizeInput = () => {
const fontSize = useSelector(
(state) => state.ui.fontSize
);
const dispatch = useDispatch();
return (
<NumberInput
value={fontSize}
onIncrement={dispatch({ type: 'increaseFontSize' })}
onDecrement={dispatch({ type: 'reduceFontSize' })}
/>
);
};
const FontSizeInput = connect(
(state) => ({
value: state.fontSize,
}),
(dispatch) => ({
onIncrement: () =>
dispatch({ type: 'increaseFontSize' }),
onDecrement: () => dispatch({ type: 'reduceFontSize' }),
})
)(NumberInput);
Reducers can be easily combined to form a "containing" reducer
example: online shop
Combining reducers could be done manually - or via the function combineReducers
from Redux
Reducers that directly manage data are usually implemented by using a switch
statement.
Reducers that are composed of other reducers can be implemented like this:
const shopReducer = (state, action) => ({
user: userReducer(state.user, action),
products: productsReducer(state.products, action),
cart: cartReducer(state.cart, action),
});
via combineReducers
:
import { combineReducers } from '@reduxjs/toolkit';
const shopReducer = combineReducers({
user: userReducer,
products: productsReducer,
cart: cartReducer,
});
ADD_TODO
), more recently alternatives have also become popular (e.g. addTodo
)"todoData/addTodo"
or "ui/showAddTodoDialog"
const action = {
type: 'todoData/todos/addTodo',
title: 'Build my first Redux app',
};
const action = {
type: 'todoData/todos/toggleTodo',
payload: 2,
};
Asynchronous actions can occur in the context of HTTP requests or from reading caches or entries in indexedDB.
In Redux asynchronous actions can be handled via middleware, e.g.:
Thunk: middleware that enables asynchronous behaviour in Redux - by dispatching functions
For example, we could call:
dispatch(loadTodosFunction);
As an asynchronous action, loadTodosFunction
would not directly affect the redux store.
Instead, it would usually lead to other actions reaching the redux store:
loadTodosRequest
is triggered immediatelyloadTodosSuccess
is triggered once the response has arrivedloadTodosError
could indicate a failureIn Thunk, the synchronous logic remains in the reducer while the asynchronous logic is included in the action.
async function loadTodos(dispatch) {
// "dispatch" is the redux store's dispatch function
// it is passed in automatically (dependency injection)
dispatch({ type: 'loadTodosRequest' });
const todos = await fetchTodos();
dispatch({ type: 'loadTodosSuccess', payload: todos });
}
We can call dispatch(loadTodos)
The complete Thunk sourcecode is just 14 lines:
https://github.com/reduxjs/redux-thunk/blob/master/src/index.js
Supply a second argument - it will receive the getState
function as its value
async function loadTodos(dispatch, getState) {
dispatch({ type: 'loadTodosRequest' });
const s = getState();
// ...
}
import { Dispatch } from '@reduxjs/toolkit';
async function loadTodos(
dispatch: Dispatch<TodosDataAction>
) {
dispatch({ type: 'loadTodosRequest' });
// ...
dispatch({ type: 'loadTodosSuccess', payload: data });
}
Action creators are usually very simple functions used to create a specific action
const addTodo = (title) => ({
type: 'addTodo',
payload: title,
});
usage:
dispatch(addTodo('groceries'));
Be aware of the double meaning: Action creators are often called actions for short in documentation.
Action creators may be necessary when using parametric actions in thunk
The following call would create and dispatch a thunk action that loads a specific todo:
dispatch(loadTodoByIndex(3));
// thunk action creator
function loadTodoByIndex(id) {
async function thunkAction(dispatch) {
dispatch({ type: 'loadTodoRequest', payload: id });
const todo = await fetchTodo(id);
dispatch({ type: 'loadTodoSuccess', payload: todo });
}
return thunkAction;
}
shorter version with nested arrow functions
// thunk action creator
const loadTodoByIndex = (id) => async (dispatch) => {
dispatch({ type: 'loadTodoRequest', payload: id });
const todo = await fetchTodo(id);
dispatch({ type: 'loadTodoSuccess', payload: todo });
};
import configureMockStore from 'redux-mock-store';
import fetchMock from 'jest-fetch-mock';
import thunk from 'redux-thunk';
import { requestTodos } from './actions';
fetchMock.enableMocks();
const mockStore = configureMockStore([thunk]);
test('loadTodos() dispatches two actions', async (done) => {
const todoData = [
{ title: 'abc', completed: false, id: 1 },
];
fetchMock.mockResponseOnce(JSON.stringify(todoData));
const store = mockStore();
const expectedActions = [
{ type: 'todoRequest' },
{ type: 'todoResponse', payload: todoData },
];
await store.dispatch(requestTodos());
expect(store.getActions()).toEqual(expectedActions);
});
Implementing a model for a todo list in Redux
data structure (example):
Actions (examples):
State consists of two important parts:
{
"cart": {
"addedIds": [2],
"quantityById": { 2: 2 }
},
"products": [
{
"id": 1,
"title": "iPad 4 Mini",
"price": 500.01,
"inventory": 2
},
{
"id": 2,
"title": "H&M T-Shirt White",
"price": 10.99,
"inventory": 10
},
{
"id": 3,
"title": "Charli XCX - Sucker CD",
"price": 19.99,
"inventory": 5
}
]
}
The two parts - cart
and products
- can be managed by two different reducers.
Combining the reducers into one reducer via the function combineReducers
:
import { combineReducers } from 'redux';
const shopReducer = combineReducers({
cart: cartReducer,
products: productsReducer,
});
const store = createStore(
shopReducer,
composeWithDevTools(applyMiddleware())
);
const cartReducer = (state = {}, action) => {
switch (action.type) {
case 'addToCart':
return {
...state,
[action.payload]: (state[action.payload] || 0) + 1,
};
default:
return state;
}
};
const products = [];
const productsReducer = (state = products, action) => {
switch (action.type) {
case 'setProducts':
return action.payload;
case 'addToCart':
return state.map(product =>
product.id === action.payload
? { ...product, inventory: product.inventory - 1 }
: product
);
default:
return state;
}
};
The createAction
function from Redux toolkit can help with creating action creators and providing string constants for action types:
import { createAction } from '@reduxjs/toolkit';
// create an action creator
const addTodo = createAction('addTodo', (title) => ({
payload: title,
}));
const action1 = addTodo('groceries');
createAction
attaches a type
property to each action creator:
addTodo.type; // 'addTodo'
using the type
property in a reducer's switch statement:
const todosReducer = (oldState = initialState, action) => {
switch (action.type) {
case addTodo.type:
// ...
case deleteTodo.type:
// ...
}
};
createAction
provides a custom .toString()
method on each action creator:
addTodo.toString(); // 'addTodo'
String(addTodo); // 'addTodo'
This can become useful when using createReducer
.
createReducer
can simplify writing reducers by:
"traditional" implementation of a counterReducer
:
const counterReducer = (state = 0, action) => {
switch (action.type) {
case 'increment':
return state + (action.payload || 1);
case 'decrement':
return state - (action.payload || 1);
default:
return state;
}
};
simplified implementation via createReducer
:
import { createReducer } from '@reduxjs/toolkit';
const counterReducer = createReducer(0, {
increment: (state, action) =>
state + (action.payload || 1),
decrement: (state, action) =>
state - (action.payload || 1),
});
implementation with TypeScript - this enables better type inference:
const counterReducer = createReducer(0, (builder) => {
builder.addCase(
'increment',
(state, action) => state + (action.payload || 1)
);
builder.addCase(
'decrement',
(state, action) => state - (action.payload || 1)
);
});
With createReducer
we can mutate existing state (see logIn
) - this is possible via immer.js
in the background
Returning derived state is possible as well (see logOut
)
const initialState = { loggedIn: false, userId: null };
const userReducer = createReducer(initialState, {
logIn: (state, action) => {
state.loggedIn = true;
state.userId = action.payload.userId;
},
logOut: (state, action) => {
return { loggedIn: false, userId: null };
},
});
if we've used createAction we can use the action creator as the key (because of its .toString()
method):
const increment = createAction('increment', (amount) => ({
amount: amount,
}));
const decrement = createAction('decrement', (amount) => ({
amount: amount,
}));
const counterReducer = createReducer(0, {
[increment]: (state, action) =>
state + (action.payload || 1),
[decrement]: (state, action) =>
state - (action.payload || 1),
});
uses createAction
and createReducer
behind the scenes
import { createSlice } from '@reduxjs/toolkit';
const todosSlice = createSlice({
name: 'todoData/todos',
initialState: [],
reducers: {
addTodo: (state, action) => {
state.push({
title: action.title,
completed: false,
id: Math.max(0, ...state.map((t) => t.id)) + 1,
});
},
deleteTodo: (state, action) =>
state.filter((todo) => todo.id !== action.id),
},
});
the call will create:
a reducer (todosSlice.reducer
)
two action creators:
todosSlice.actions.addTodo
(action type: "todoData/todos/addTodo"
)todosSlice.actions.deleteTodo
(action type: "todoData/todos/deleteTodo"
)calling an action creator:
addTodo('groceries');
{
"type": "todoData/todos/addTodo",
"payload": "groceries"
}
Best practice in Redux: always store the minimal state tree (i.e. no redundant data)
Examples of non-conforming states:
{
todos: [...],
maxTodoId: 3
}
{
shoppingCartItems: [{itemid: ..., price: ...}, ...],
totalPrice: ...
}
Data like maxTodoId
and totalPrice
can be computed from the other data and shouldn't have an extra entry in the state.
Selector = function that computes derived data based on a minimal state
Selectors receive the entire state as their argument and return derived data.
getMaxTodoId
getFilteredTodos
const getMaxTodoId = (state) =>
state.todos.reduce((aggregator, item) =>
Math.max(aggregator, item.id, 1)
);
const getFilteredTodos = (state) =>
state.todos.filter((todo) =>
todo.title
.toLowerCase()
.includes(state.ui.filterText.toLowerCase())
);
Memoization is the practice of caching return values of a pure function so they don't need to be recomputed every time
Reselect: library for memoization of selectors
Reselect can be used for memoizing complex selectors
import { createSelector } from 'reselect';
// normal selector
const todosSelector = (state) => state.todoData.todos;
// memoized selector
const numCompletedTodosSelector = createSelector(
todosSelector,
(todos) => todos.filter((todo) => todo.completed).length
);
numCompletedTodosSelector
depends on todosSelector
and will only be evaluated if todosSelector
returns a new value.
const lengthSelector = (rect) => rect.length;
const widthSelector = (rect) => rect.width;
const areaSelector = (rect) =>
lengthSelector(rect) * widthSelector(rect);
const memoizedAreaSelector = createSelector(
lengthSelector,
widthSelector,
// will only be evaluated if one of the selectors
// returned a new value
(length, width) => length * width
);
The last function call will not recompute the area
areaSelector({ length: 2, width: 3, col: 'red' });
areaSelector({ length: 2, width: 3, col: 'blue' });
memoizedAreaSelector({ length: 2, width: 3, col: 'red' });
memoizedAreaSelector({ length: 2, width: 3, col: 'blue' });
Middleware can be added to a Redux store.
It provides an extension and can interfere between dispatching an action and the moment it reaches the reducer.
const myLogger = (store) => (next) => (action) => {
console.log(action);
next(action);
};
import {
getDefaultMiddleware,
configureStore,
} from '@reduxjs/redux-toolkit';
const store = configureStore({
reducer: rootReducer,
middleware: [...getDefaultMiddleware(), myLogger],
});
example usage:
dispatch({
type: 'fetchJson',
url: 'https://jsonplaceholder.typicode.com/todos',
});
In the background the action fetchJson
should dispatch two separate actions:
fetchJsonStart
fetchJsonComplete
(this action should contain the json content)const fetcher = (store) => (next) => (action) => {
if (action.type === 'fetchJson') {
store.dispatch({ type: 'fetchJsonStart' });
fetch(action.payload.url)
.then((response) => response.json())
.then((data) => {
store.dispatch({
type: 'fetchJsonComplete',
payload: {
url: action.payload.url,
data: data,
},
});
});
} else {
next(action);
}
};
const myThunk = (store) => (next) => (action) => {
if (typeof action === 'function') {
// we pass dispatch to the action function
// so the action can use it
return action(store.dispatch);
} else {
return next(action);
}
};
Like Thunk, Saga is a middleware that enables asynchronous behaviour in Redux
npm package: redux-saga
import {
getDefaultMiddleware,
configureStore,
} from '@reduxjs/redux-toolkit';
import createSagaMiddleWare from 'redux-saga';
const sagaMiddleware = createSagaMiddleware();
const store = configureStore({
reducer: rootReducer,
middleware: [...getDefaultMiddleware(), sagaMiddleware],
});
A saga is like a separate thread in our application that is responsible for side effects.
import todoSaga from './todosaga';
sagaMiddleware.run(todoSaga);
Sagas are defined as generators
The following code causes any todosFetchRequest
action to be handled by fetchTodos
(which we will define as a generator)
import { takeEvery } from 'redux-saga/effects';
function* todoSaga() {
yield takeEvery('todosFetchRequest', fetchTodos);
yield takeEvery('usersFetchRequest', fetchUsers);
}
export default todoSaga;
Asynchronous functions via async
and await
are part of JavaScript since ES2017
const url = 'https://jsonplaceholder.typicode.com/todos';
async function fetchTodos() {
const response = await fetch(url);
const todoData = await response.json();
console.log(todoData);
}
Redux-Saga implements something very similar via generators:
const url = 'https://jsonplaceholder.typicode.com/todos';
function* fetchTodos() {
const response = yield fetch(url);
const todoData = yield response.json();
console.log(todoData);
}
For details on generators see the next section
via put
:
import { put } from 'redux-saga/effects';
function* fetchTodos() {
const response = yield fetch(url);
const todoData = yield response.json();
yield put({
type: 'todosFetchSuccess',
payload: todoData,
});
}
import { put } from 'redux-saga/effects';
function* fetchTodos() {
const response = yield fetch(url);
if (response.ok) {
const todoData = yield response.json();
yield put({
type: 'todosFetchSuccess',
payload: todoData,
});
} else {
yield put({
type: 'todosFetchError',
});
}
}
Iterable = object which can be iterated over via for ... of
Examples: Arrays, Iterators
Iterables define a Method under the symbol Symbol.iterator
Superficially: An iterator is a particular object that can be iterated over via for (let item of iterator)
.
More precisely: An iterator is a particular object that has a method named next
.
Iterators can be created in various ways.
A generator function is one way to to create an iterator. A generator function can be entered an left repeatedly. It will remember its state in the meantime.
A generator function is defined with the keyword function*
. Instead of return
statements it will contain yield
statements.
function* countTo100() {
let i = 1;
while (i <= 100) {
yield i;
i++;
}
}
usage:
for (let i of countTo100()) {
console.log(i);
}
const c = countTo100();
const firstEnetry = c.next();
console.log(firstEntry.value);
const secondEntry = c.next();
console.log(secondEntry.value);