📌 Learning objectives:
- understand what Redux is
- learn how to write reducers, action types/creators
- know how to use Redux with React
Redux is a predictable state container for JavaScript apps. It can be used without React and React does not require it.
Yet, it helps build flexible and maintainable applications.
Redux asks you to describe:
- application state as plain objects and arrays
- changes in the system as plain objects
- the logic for handling changes as pure functions
The state of your whole application is stored in an object tree within a single store. This is what Redux calls the "single source of truth".
The only way to mutate the state is to emit an action, an object describing what happened. The state is read-only.
To specify how the state tree is transformed by actions, you write pure reducers.
These are called "pure" because they do nothing but return a value based on their parameters. They have no side effects into any other part of the system.
Actions are payloads of information that send data from your application to your
store. Actions are sent to the store using store.dispatch()
:
const MY_ACTION = 'MY_ACTION';
const MY_ACTION_WITH_ARGS = 'MY_ACTION_WITH_ARGS';
store.dispatch({ type: MY_ACTION });
store.dispatch({ type: MY_ACTION_WITH_ARGS, id, name });
Action creators are functions that create actions:
export const myAction = () => ({ type: MY_ACTION });
export const myActionWithArgs = (id, name) => ({
type: MY_ACTION_WITH_ARGS,
id,
name,
});
dispatch(myAction());
dispatch(myActionWithArgs(123, 'John Doe'));
The job of reducers is to describe how the application's state changes in response to something that happened (which is described by an action):
// src/reducers/app.js
const initialState = {};
export default function reducer(state = initialState, action = {}) {
switch (action.type) {
case MY_ACTION:
// ...
default:
return state;
}
}
The store has the following responsibilities:
- holds application state
- allows access to state via
getState()
- allows state to be updated via
dispatch(action)
- registers listeners via
subscribe(listener)
- handles unregistering of listeners via the function returned by
subscribe(listener)
Ducks is a proposal for bundling reducers, action types and actions when using Redux. A Ducks module...
- MUST
export default
areducer()
function - MUST
export
its action creators as functions - MAY export its action types as
UPPER_SNAKE_CASE
, if an external reducer needs to listen for them, or if it is a published reusable library
const initialState = {};
// actions (types)
const ACTION = 'ACTION';
// action creators
export const action = () => {
return { type: ACTION };
};
// reducer
export default function reducer(state = initialState, action = {}) {
// TODO: implement me
return state;
}
$ yarn add react-redux redux
// src/index.js
// ...
const store = configureStore();
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
What does this configureStore()
method do?
// src/store/configureStore.js
import { applyMiddleware, createStore } from 'redux';
import rootReducer from 'reducers';
const middleware = [];
const createStoreWithMiddleware = applyMiddleware(...middleware)(
createStore
);
export default function configureStore(initialState) {
const store = createStoreWithMiddleware(rootReducer, initialState);
return store;
}
Heh. What is this rootReducer
?
// src/reducers/index.js
import { combineReducers } from 'redux';
import app from './app';
// ...other import for reducers
export default combineReducers({
app,
// ...other reducers
});
React Redux provides a connect()
High Order Component (HOC) that allows to
connect any React component to the application store.
// src/App.js
// ...
export default connect(mapStateToProps, mapDispatchToProps)(App);
mapStateToProps()
tells how to transform the current Redux store state into the props you want to pass to the component wrapped withconnect()
mapDispatchToProps()
receives thedispatch()
method and returns callback props that you want to inject into the component
const mapStateToProps = (state) => {
return {
sequences: state.app.sequences,
};
};
const mapDispatchToProps = (dispatch) => {
return {
onAddSequence: () => dispatch(addSequence(generate())),
};
};
Containers know about data, its shape and where it comes from. We also call them "connected" components.
Presentational components are concerned with how the things look, with no internal state (functional components).
Presentational | Container | |
---|---|---|
Aware of Redux | No | Yes |
To read data | Read data from props | Subscribe to Redux state |
To change data | Invoke callbacks from props | Dispatch Redux actions |
Are written | By hand | Usually generated by React Redux |
src/App
├── index.js # Container/Connected component
├── presenter.js # Presentational component
└── styles.css
// src/App/index.js
import { connect } from 'react-redux';
import { addSequence } from 'reducers/app';
import App from './presenter';
const mapStateToProps = state => { /* ... */ };
const mapDispatchToProps = dispatch => { /* ... */ };
export default connect(mapStateToProps, mapDispatchToProps)(App);
Install the Redux DevTools Extension in your browser.
// src/store/configureStore.js
export default function configureStore(initialState) {
const store = createStoreWithMiddleware(
rootReducer,
initialState,
typeof window !== 'undefined' &&
window.__REDUX_DEVTOOLS_EXTENSION__ &&
window.__REDUX_DEVTOOLS_EXTENSION__()
);
return store;
}
$ yarn add --dev redux-logger
// src/store/configureStore.js
const isNotProduction = process.env.NODE_ENV !== 'production';
const middleware = [];
if (isNotProduction) {
const { logger } = require('redux-logger');
middleware.push(logger);
}
- Install Redux, React Redux and configure it into your application (install the devtools to ease your life)
- Create a reducer in
src/reducers/app.js
- Create the actions to add, remove and select sequences and replace the
existing code, i.e. move the state from
src/App.js
to your reducer - Use the directory structure for the
App
component
Now, your test suite should fail because you are testing the container/connected component.
Another advantage of the "directory structure" for components is that you can test the presentational components in isolation.
// src/App/presenter.test.js
import React from 'react';
import { shallow } from 'enzyme';
import App from './presenter';
it('renders without crashing', () => {
const wrapper = shallow(
<App
sequences={[]}
onAddSequence={jest.fn()}
onRemoveSequence={jest.fn()}
onSelectSequence={jest.fn()}
/>
);
expect(wrapper.find('.App')).toHaveLength(1);
});
- 179
You can export
and test the map*ToProps()
.
If you want to write "integration tests", you can mount()
the component you
want to test into <Provider />
and manually create a store
.
import React from 'react';
import { mount } from 'enzyme';
import { Provider } from 'react-redux';
import configureStore from 'store/configureStore';
import Home from './index';
it('renders without crashing', () => {
const store = configureStore();
const wrapper = mount(
<Provider store={store}>
<Home />
</Provider>
);
expect(wrapper.find('.Home')).toHaveLength(1);
});
Pure functions are easily testable, i.e. reducers and action creators can be fully unit tested (with Jest).
describe('reducer()', () => {
it('initializes properly', () => {
const state = reducer(undefined);
expect(state).toEqual({
currentSequenceId: null,
sequences: [],
});
});
it('can add a sequence', () => {
const sequence = generate();
const state = reducer(undefined, addSequence(sequence));
expect(state.sequences).toContain(sequence);
});
// ...
});
Selectors can compute derived data, allowing Redux to store the minimal possible state. They are composable and they can be used as input to other selectors.
See also: the reselect library.
const mapStateToProps = (state) => {
return {
incompleteItems: state.listOfItems.filter((item) => {
return !item.completed;
});
};
};
- Implementation of
incompleteItems
may change - Computation logic occurs in
mapStateToProps()
- Cannot memoize the values of
incompleteItems
Introduce selectors into your reducers:
const getIncompleteItems = (state) => {
return state.listOfItems.filter((item) => {
return !item.completed;
});
};
const mapStateToProps = (state) => {
return {
incompleteItems: getIncompleteItems(state),
};
};
- Add unit tests for the
app
reducer (app.test.js
) - Add a
getCurrentSequence()
selector and use it in your connectedApp
component