Siehe Präsentation zu React advanced
Browser Plugin für Firefox / Chrome:
https://github.com/zalmoxisus/redux-devtools-extension
Anzeigen des Redux States via:
Browser-Devtools (F12) → Redux → State → Chart/Tree
Websites, die Redux verwenden (wir können den Redux State betrachten):
Funktionalität:
Mit Redux toolkit ist ein vereinfachtes Setup von Redux und verwandten Libraries möglich (ähnlich wie bei create-react-app)
Wir werden es in dieser Präsentation durchgehend verwenden.
npm-Paket: @reduxjs/toolkit
Funktionalität (siehe what's included):
Bei Redux: Anwendungszustand wird global gespeichert.
Der Zustand wird unabhängig von den React Komponenten gespeichert.
Es gibt einen Store, in dem alle Daten gesammelt sind.
Ein Store kann in verschiedene Teile aufgeteilt sein.
Besonderheiten von Redux Reducern (verglichen mit dem Reducer Hook):
der Anfangszustand wird als Standardparameter übergeben:
const initialState = []
const todosReducer = (oldState = initialState, action) => {...}
unbekannte Actions sollten den Zustand unverändert lassen:
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';
oder:
import todosReducer from './todosReducer';
type TodosAction = Parameters<typeof todosReducer>[1];
type TodosState = ReturnType<typeof todosReducer>;
Erstellen eines Redux Stores, der den State enthält; der Store wird von einem Reducer verwaltet
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './state/todos';
const todosStore = configureStore({
reducer: todosReducer,
});
Direkte Verwendung des Stores:
console.log(todosStore.getState());
todosStore.dispatch({
type: 'addTodo',
payload: 'learn Redux',
});
console.log(todosStore.getState());
npm Pakete:
react-redux
@types/react-redux
Provider: zum Hinzufügen eines Redux-Stores zu einer React-App
Alle Unterkomponenten des Providers haben Zugriff auf den Store
// index.js
import { Provider } from 'react-redux';
[...]
ReactDOM.render(
<Provider store={myStore}>
<App/>
</Provider>
...
);
Mit useSelector
können wir die Inhalte des Redux-Stores abfragen.
Wir übergeben eine sogenannte Selektorfunktion an useSelector
.
Die Selektorfunktion erhält den gesamten Redux-State und gibt einen daraus abgeleiteten Wert zurück.
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
);
// ...
};
erhalten des State-Types:
// rootReducer.ts
export type State = Array<Todo>;
oder
// store.ts
export type State = ReturnType<typeof todosStore.getState>;
verwenden mit useSelector
:
useSelector((state: State) => state.length);
Mit useDispatch
können wir aus React auf die dispatch
-Funktion des Redux-Stores zugreifen und damit Actions dispatchen.
import { useDispatch } from 'react-redux';
const TodoList = () => {
const dispatch = useDispatch();
...
dispatch({ type: 'deleteCompletedTodos' });
};
import { Dispatch } from '@reduxjs/toolkit';
const dispatch = useDispatch<Dispatch<TodoAppAction>>();
Oft sinnvolle Einteilung:
presentational components:
container components:
Beispiel:
Allgemeine TodoList
-Komponente mit folgenden Props/Events:
todos
onToggle
onDelete
TodoListContainer
-Komponente, die die TodoList
-Komponente mit dem Redux-Store verbindet:
TodoList
erhalten Werte aus dem State des Redux StoresTodoList
lösen im Redux Store Actions ausManuelle Verbindung:
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 })
}
/>
);
};
Verbindung mittels connect
:
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);
Verbindung mittels connect
:
connect
erhält zwei Funktionen als Argumente; diese können auch separat definiert sein und tragen meist die Namen mapStateToProps
und mapDisptachToProps
:
import { connect } from 'react-redux';
const ComponentContainer = connect(
mapStateToProps,
mapDispatchToProps
)(Component);
Wir verbinden eine einfache NumberInput
-Komponente mit dem Redux Store:
const NumberInput = ({
value,
onIncrement,
onDecrement,
}) => (
<div>
<button onClick={onDecrement}>-</button>
{value}
<button onClick={onIncrement}>+</button>
</div>
);
Interface der NumberInput
-Komponente:
value
onIncrement
onDecrement
Interface des Redux Stores:
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);
Mehrere Reducer können einfach zusammengeführt / kombiniert werden, um einen zusammengesetzten Reducer zu erstellen
Beispiel: Online Shop
Kombinieren könnte manuell erfolgen - oder durch Verwendung der Funktion combineReducers
aus Redux
Reducer, die direkt Daten verwalten, werden wie bekannt meist mit switch
-Statements implementiert.
Reducer, die sich aus anderen Reducern zusammensetzen, können wie folgt implementiert werden:
const shopReducer = (state, action) => ({
user: userReducer(state.user, action),
products: productsReducer(state.products, action),
cart: cartReducer(state.cart, action),
});
Zusammensetzen von Reducern mittels combineReducers
:
import { combineReducers } from '@reduxjs/toolkit';
const shopReducer = combineReducers({
user: userReducer,
products: productsReducer,
cart: cartReducer,
});
ADD_TODO
), heute sind auch andere Schreibweisen üblich (z.B. addTodo
)"todoData/addTodo"
oder "ui/showAddTodoDialog"
payload
-Property definiert, sowie error
und meta
const action = {
type: 'todoData/todos/addTodo',
title: 'Build my first Redux app',
};
const action = {
type: 'todoData/todos/toggleTodo',
payload: 2,
};
Asynchrone Actions betreffen beispielsweise HTTP-Anfragen oder das Abfragen von Caches oder indexedDB-Einträgen.
Asynchrone Actions können in Redux mit middleware realisiert werden, z.B.:
Thunk ist Middleware, die asynchrones Verhalten in Redux - durch das Dispatchen von Funktionen - ermöglicht
Beispielhafter Aufruf:
dispatch(getTodosFunction);
Als asynchrone Funktion wird loadTodosFunction
nicht direkt den Redux-Store verändern.
Stattdessen werden zwei andere Actions den store erreichen:
loadTodosRequest
wird sofort ausgelöstloadTodosSuccess
wird ausgelöst, sobald die Netzwerkanfrage erfolgreich warloadTodosError
würde einen Fehler anzeigenIn Thunk verbleibt die synchrone Logik im Reducer, die asynchrone Logik wird in die Action aufgenommen.
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 });
}
Wir können dispatch(loadTodos)
aufrufen
Der komplette Thunk Sourcecode sind nur 14 Zeilen:
https://github.com/reduxjs/redux-thunk/blob/master/src/index.js
Ein zweites Argument kann optional übergeben werden: Es erhält die getState
-Funktion als Wert.
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: einfache Funktionen, die eine bestimmte Action erstellen
const addTodo = (title) => ({
type: 'addTodo',
payload: title,
});
Verwendung:
dispatch(addTodo('groceries'));
Achtung doppelte Bedeutung: Action Creators werden oft abgekürzt als Actions bezeichnet (z.B. in Dokumentation).
Action Creators können notwendig sein, um parametrische Actions in Thunk zu benutzen
Der folgende Aufruf würde eine Thunk Action erstellen und dispatchen, die ein bestimmtes Todo lädt:
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;
}
kürzere Version mit verschachtelten Pfeilfunktionen:
// 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);
});
Umsetzung eines Modells für eine Todoliste in Redux
Datenstruktur (Beispiel):
Actions (Beispiel):
State des Beispiels besteht aus zwei wichtigen Teilen:
{
"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
}
]
}
Die zwei Teile - cart
und products
- können von zwei verschiedenen Reducern verwaltet werden.
Zusammenführen zu einem Reducer mittels der vordefinierten Funktion 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;
}
};
Die Funktion createAction
aus dem Redux toolkit kann hilfreich sein, um action creators zu erstellen und String-konstanten für action types bereit zu stellen:
import { createAction } from '@reduxjs/toolkit';
// create an action creator
const addTodo = createAction('addTodo', (title) => ({
payload: title,
}));
const action1 = addTodo('groceries');
createAction
versieht jeden Action Creator mit einer type
property:
addTodo.type; // 'addTodo'
Verwendung der type
Property in dem switch-Statement eines Reducers:
const todosReducer = (oldState = initialState, action) => {
switch (action.type) {
case addTodo.type:
// ...
case deleteTodo.type:
// ...
}
};
createAction
stellt eine eigene .toString()
-Methode bei jedem Action Creator bereit:
addTodo.toString(); // 'addTodo'
String(addTodo); // 'addTodo'
Dies kann bei der Verwendung von createReducer
hilfreich sein.
createReducer
kann das Schreiben von Reducern vereinfachen:
Ãœbliche Implementierung eines counterReducer
s:
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;
}
};
Vereinfachte Implementierung mittels createReducer
:
import { createReducer } from '@reduxjs/toolkit';
const counterReducer = createReducer(0, {
increment: (state, action) =>
state + (action.payload || 1),
decrement: (state, action) =>
state - (action.payload || 1),
});
Implementierung für TypeScript - dies ermöglicht das feststellen von Typen:
const counterReducer = createReducer(0, (builder) => {
builder.addCase(
'increment',
(state, action) => state + (action.payload || 1)
);
builder.addCase(
'decrement',
(state, action) => state - (action.payload || 1)
);
});
Bei Verwendung von createReducer
dürfen wir den alten State abändern (siehe logIn
) - dies ist durch die Verwendung von immer.js
im Hintergrund möglich
Das Zurückgeben von abgeleitetem State ist ebenfalls möglich (siehe 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 };
},
});
Bei Verwendung von createAction
können wir den Action Creator direkt als Key verwenden (wegen dessen .toString()
-Methode):
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),
});
verwendet im Hintergrund createAction
und createReducer
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),
},
});
der gezeigte Aufruf erstellt:
einen Reducer (todosSlice.reducer
)
zwei Action Creators:
todosSlice.actions.addTodo
todosSlice.actions.deleteTodo
aufruf eines Action Creators:
addTodo('groceries');
{
"type": "todoData/todos/addTodo",
"payload": "groceries"
}
Best Practice in Redux: Immer den minimalen State speichern (keine redundanten Daten)
Negativbeispiele:
{
todos: [...],
maxTodoId: 3
}
{
shoppingCartItems: [{itemid: ..., price: ...}, ...],
totalPrice: ...
}
Daten wie maxTodoId
und totalPrice
können aus den anderen Daten abgeleitet werden und sollten keinen separaten Eintrag im State haben.
Selektor = Funktion, die abgeleitete Daten aus einem minimalen State errechnet
Ein Selektor erhält den ganzen State als Argument und gibt abgeleitete Daten zurück
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())
);
Memoisierung bezeichnet das Cachen von Rückgabewerten reiner Funktionen
Reselect = Library für Memoisierung von Selektoren.
Reselect kann zum Memoisieren komplexerer Selektoren verwendet werden
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
);
Der numCompletedTodosSelector
ist vom todosSelector
abhängig und wird nur neu asgewertet, wenn dieser einen neuen Wert zurückgibt.
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
);
Der letzte Funktionsaufruf wird die Fläche nicht neu berechnen:
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' });
kann zu einem Redux Store hinzugefügt werden
Erweiterungspunkt / Eingriffspunkt zwischen dem Dispatchen einer Aktion und dem Zeitpunkt an dem sie beim Reducer eintrifft
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],
});
Beispielhafte Nutzung:
dispatch({
type: 'fetchJson',
url: 'https://jsonplaceholder.typicode.com/todos',
});
Die action fetchJson
sollte im Hintergrund zwei einzelne actions dispatchen:
fetchJsonStart
fetchJsonComplete
(diese enthält auch JSON-daten als payload)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 call it
return action(store.dispatch);
} else {
return next(action);
}
};
Wie auch bei Thunk handelt es sich bei Saga um Middleware, die asynchrones Verhalten in Redux ermöglicht
npm-Paket: 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],
});
Ein Saga ist ähnlich einem separaten Thread in unserer Anwendung, der für side effects verantwortlich ist.
import todoSaga from './todosaga';
sagaMiddleware.run(todoSaga);
Sagas werden als Generators definiert
Der folgende Code bewirkt, dass z.B. todosFetchRequest
von fetchTodos
behandelt wird (welches wir als Generator erstellen werden).
import { takeEvery } from 'redux-saga/effects';
function* todoSaga() {
yield takeEvery('todosFetchRequest', fetchTodos);
yield takeEvery('usersFetchRequest', fetchUsers);
}
export default todoSaga;
Asynchrone Funktionen mittels async
und await
sind seit ES2017 im JavaScript Standard
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 setzt etwas ganz ähnliches mittels Generators um:
const url = 'https://jsonplaceholder.typicode.com/todos';
function* fetchTodos() {
const response = yield fetch(url);
const todoData = yield response.json();
console.log(todoData);
}
(Benötigter Code zum Ausführen dieses Beispiels)
für Details zu Generators siehe nächster Abschnitt
mittels 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 = Objekt, über das mit for ... of
iteriert werden kann
Beispiele: Arrays, Iterators
Iterables definieren eine Methode unter dem Symbol Symbol.iterator
Oberflächlich: Ein Iterator ist ein besonderes Objekt, über das wir mit for (let item of iterator)
iterieren können.
Genauer Hintergrund: Ein Iterator ist ein besonderes Objekt, das eine next
-Methode besitzt.
Iterators können auf verschiedene Arten erzeugt werden.
Eine Generator-Funktion ist eine Möglichkeit, einen Iterator zu erstellen. Eine Generator-Funktion kann wiederholt betreten und verlassen werden. Sie "merkt" sich in der Zwischenzeit ihren Zustand.
Eine Funktion kann mit function*
definiert werden und anstatt eines return
-Statements ein yield
Statement enthalten - sie wird damit zu einer Generator-Funktion, die beim Aufruf einen Iterator zurückgibt.
function* countTo100() {
let i = 1;
while (i <= 100) {
yield i;
i++;
}
}
Verwendung:
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);