Managing State in React: Redux or not Redux?
At Marmelab we really like to manage the state of React apps using Redux. Its emergence has transformed the way we code our applications: immutability, functional programming, asynchronous API call management with Redux-Saga generators... So much that we sometimes tend to "de facto" integrate Redux into our project start stack. But is that a good idea? Not sure...
An Example: Managing Meetups With React
Let's take a straightforward meetup management application. It should be able to display:
- a list of proposals,
- a wish list of talks,
- a list of meetup members.
The data comes from a REST API. A login/password protects both the application and the API.
The application is bootstrapped with Create React App and upgraded with:
This is what the project looks like:
codesandbox.io might track you and we would rather have your consent before loading this.
The application reflects the typical redux architecture. It starts with an <App />
component that mounts the redux store (<Provider store={store}>
) and the router (<ConnectedRouter history={history}>
):
// in App.js
...
export const App = ({ store, history }) => (
<Provider store={store}>
<ConnectedRouter history={history}>
<Container>
<Header />
<Switch>
<Route exact path="/" component={Home} />
<Route path="/talks" component={Talks} />
<Route path="/wishes" component={Wishes} />
<Route path="/members" component={Members} />
<Route path="/login" component={Authentication} />
<Route component={NoMatch} />
</Switch>
</Container>
</ConnectedRouter>
</Provider>
);
Redux users will be comfortable with the file structure that I chose. I grouped all the code related to a feature into a directory. An example with the talks
page:
├── talks
│ ├── actions.js
│ ├── reducer.js
│ ├── sagas.js
│ └── Talks.js
The <Talks>
page component is a straightforward "connected component":
// in talks/Talks.js
export const Talks = ({ isLoading, talks }) => (
<div>
<h1>Talks</h1>
{isLoading && <Spinner />}
{talks && talks.map(talk => <h2 key={talk.id}>{talk.title}</h2>)}
</div>
);
const mapStateToProps = ({ talks }) => ({
isLoading: talks.isLoading,
talks: talks.data,
});
// passing {} as the second's connect argument prevents it to pass dispatch as prop
const mapDispatchToProps = {};
export default connect(
mapStateToProps,
mapDispatchToProps
)(Talks);
The data for the talks is not fetched on componentWillMount
, but through a saga listening to route changes:
// in talks/sagas.js
import { put, select, takeLatest } from "redux-saga/effects";
import { LOCATION_CHANGE } from "react-router-redux";
import { loadTalks } from "./actions";
const hasData = ({ talks }) => !!talks.data;
export function* handleTalksLoading() {
if (yield select(hasData)) {
return;
}
yield put(loadTalks());
}
export const sagas = function*() {
yield takeLatest(
action =>
action.type === LOCATION_CHANGE && action.payload.pathname === "/talks",
handleTalksLoading
);
};
When the route changes and corresponds to the talks section (action.type === LOCATION_CHANGE && action.payload.pathname === '/talks'
), my application triggers an action with the loadTalks
function:
// in talks/actions.js
export const LOAD_TALKS = "LOAD_TALKS";
export const loadTalks = payload => ({
type: "LOAD_TALKS",
payload,
meta: {
request: {
url: "/talks",
},
},
});
This action, containing the url to get data for talks inside its meta, will be intercepted by a generic fetch saga action => !!action.meta && action.meta.request
:
// in /services/fetch/fetchSagas.js
import { call, put, takeEvery, select } from "redux-saga/effects";
import { appFetch as fetch } from "./fetch";
export const fetchError = (type, error) => ({
type: `${type}_ERROR`,
payload: error,
meta: {
disconnect: error.code === 401,
},
});
export const fetchSuccess = (type, response) => ({
type: `${type}_SUCCESS`,
payload: response,
});
export function* executeFetchSaga({ type, meta: { request } }) {
const token = yield select(state => state.authentication.token);
const { error, response } = yield call(fetch, request, token);
if (error) {
yield put(fetchError(type, error));
return;
}
yield put(fetchSuccess(type, response));
}
export const sagas = function*() {
yield takeEvery(
action => !!action.meta && action.meta.request,
executeFetchSaga
);
};
Once the fetch is successful, the saga triggers a final action indicating the success of the data recovery (createAction('${type}_SUCCESS')(response)
). This action is used by the talks reducer:
// in talks/reducers.js
export const reducer = (state = defaultState, action) => {
switch (action.type) {
case LOAD_TALKS:
return {
...state,
loading: true,
};
case LOAD_TALKS_ERROR:
return {
...state,
loading: false,
error: action.payload,
};
case LOAD_TALKS_SUCCESS:
return {
...state,
loading: false,
data: action.payload,
};
case LOGOUT:
return defaultState;
default:
return state;
}
};
It works well. That's pretty smart, even elegant! The use of action's meta allows sharing generic behaviours within the application (data fetching but also error handling or logout).
It's Smart, But It's Complex
It's not easy to find your way around when you discover the application, some behaviours are so magical. To summarize, the app fetch the data with a redux-saga connected to the router, which sends a fetch action intercepted by another generic saga, which in case of success emits another action, action intercepted by the reducer of the page having emitted the very first action of the chain...
Some might say that it's an abusive use of redux, but it's mostly the result of several projects done on this stack, with the experience of rewriting actions and reducers.
Added to this complexity, there is also a significant amount of plumbing, i.e. many files repeated for each feature (actions, reducers and other sagas).
Let's analyse the example application with its three pages, its home and its login page:
❯ cloc services/cra_webapp/src
32 text files.
32 unique files.
0 files ignored.
github.com/AlDanial/cloc v 1.74 T=0.06 s (581.6 files/s, 17722.1 lines/s)
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
JavaScript 31 150 1 819
CSS 1 0 0 5
-------------------------------------------------------------------------------
SUM: 32 150 1 824
-------------------------------------------------------------------------------
31 files, 819 lines of code, it's already a lot for a straightforward application. This code could be simplified a little bit, with the risk of making it less generic.
It's certainly time to ask ourselves if Redux is necessary here?
Redux is a predictable state container for JavaScript apps.
But do different parts of the application modify the same data, requiring a predictable state for this data? No, I just need to display data from the API. Are there components buried in the DOM that can modify the data? No, user interactions are pretty limited.
So I probably don't need Redux.
Fetching Data Without Redux
Let's try fetching data without Redux, or more precisely without Redux-Saga (since it is not directly redux' job to perform the data fetching). I could implement all this fetch logic on each page. However, that would be setting up very repetitive mechanics and a lot of duplicated code. So I have to find a generic way to fetch data from the API without introducing too much duplication and complexity.
The render prop pattern is an excellent candidate for this kind of problem!
Let's create a DataProvider
component:
// in DataProvider.js
import React, { Component, Fragment } from "react";
import { Redirect } from "react-router";
import { appFetch } from "./services/fetch";
export class DataProvider extends Component {
static propTypes = {
render: PropTypes.func.isRequired,
url: PropTypes.string.isRequired,
};
state = {
data: undefined,
error: undefined,
};
fetchData = async props => {
const token = window.sessionStorage.getItem("token");
try {
const data = await appFetch({ url }, token);
this.setState({
data: data.response,
error: null,
});
} catch (error) {
this.setState({
error,
});
}
};
componentDidMount() {
return this.fetchData(this.props);
}
render() {
const { data, error } = this.state;
const { location } = this.props;
if (error) {
return error.code >= 401 && error.code <= 403 ? (
<Redirect to="/login" />
) : (
<p>Erreur lors du chargement des données</p>
);
}
return (
<Fragment>
{data ? (
<p>Aucune donnée disponible</p>
) : (
this.props.render({
data,
})
)}
</Fragment>
);
}
}
This component fetches data from the prop url
during the componentDidMount
. It manages error and missing data. If it gets data, it delegates the rendering to the function passed as render
prop (this.props.render({ data })
).
Let's implement this component on the talk page:
// in talks/Talks.js
import React from "react";
import PropTypes from "prop-types";
import { DataProvider } from "../DataProvider";
export const TalksView = ({ talks }) => (
<div>
<h1>Talks</h1>
{talks && talks.map(talk => <h2 key={talk.id}>{talk.title}</h2>)}
</div>
);
TalksView.propTypes = {
talks: PropTypes.array,
};
export const Talks = () => (
<DataProvider
url="/talks"
render={({ data }) => <TalksView talks={data} />}
/>
);
I now have two components:
- the
TalksView
component, that only displays data, no matter where it comes from, - the
Talks
component, using theDataProvider
to get the data andTalksView
to display itrender={({ data }) => <TalksView talks={data} />}
.
It's simple, effective and readable!
I am now ready to remove Redux from the application.
codesandbox.io might track you and we would rather have your consent before loading this.
Let's relaunch the analysis of our project:
❯ cloc services/cra_webapp/src
16 text files.
16 unique files.
0 files ignored.
github.com/AlDanial/cloc v 1.74 T=0.04 s (418.9 files/s, 13404.6 lines/s)
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
JavaScript 15 64 1 442
CSS 1 0 0 5
-------------------------------------------------------------------------------
SUM: 16 64 1 447
-------------------------------------------------------------------------------
So I went from 819 lines of code to 442 lines, almost half as much. Not bad!
Replacing The Redux Store By React State
In the current state, each page gets data using the DataProvider. However, my application requires authentication to obtain user information through a json-web-token.
How will this user information be transmitted to the individual components without the Redux store? Well, by using the state of the higher level component (App.js
), and passing the user
as a prop to the child components that need it (PrivateRoute.js
, Header.js
).
In short, let's make React code again!
// in App.js
import React, { Component } from "react";
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
import { Authentication } from "./authentication/Authentication";
import { Header } from "./components/Header";
import { PrivateRoute } from "./PrivateRoute";
import { Talks } from "./talks/Talks";
export class App extends Component {
state = {
user: null,
};
decodeToken = token => {
const user = decode(token);
this.setState({ user });
};
componentWillMount() {
const token = window.sessionStorage.getItem("token");
if (token) {
this.decodeToken(token);
}
}
handleNewToken = token => {
window.sessionStorage.setItem("token", token);
this.decodeToken(token);
};
handleLogout = () => {
window.sessionStorage.removeItem("token");
this.setState({ user: null });
};
render() {
const { user } = this.state;
return (
<Router>
<div>
<Header user={user} onLogout={this.handleLogout} />
<Switch>
<PrivateRoute path="/talks" render={() => <Talks />} user={user} />
<Route
path="/login"
render={({ location }) => (
<Authentication
location={location}
onNewToken={this.handleNewToken}
/>
)}
/>
</Switch>
</div>
</Router>
);
}
}
Note: I know: storing the token
in window.sessionStorage
is a bad practice. But this allows me to quickly set up authentication for the sake of this example. This has nothing to do with the removal of Redux.
// in PrivateRoute.js
import React from "react";
import PropTypes from "prop-types";
import { Redirect, Route } from "react-router";
/**
* This Route will redirect the user to the login page if needed.
*/
export const PrivateRoute = ({ user, ...rest }) =>
user ? (
<Route {...rest} />
) : (
<Redirect
to={{
pathname: "/login",
state: { from: rest.location },
}}
/>
);
PrivateRoute.propTypes = {
user: PropTypes.object,
};
// in components/Header.js
import React from "react";
import PropTypes from "prop-types";
import { Navigation } from "./Navigation";
export const Header = ({ user, onLogout }) => (
<header>
<h1>JavaScript Playground: meetups</h1>
{user && <Navigation onLogout={onLogout} />}
</header>
);
Header.propTypes = {
user: PropTypes.object,
onLogout: PropTypes.func.isRequired,
};
My application being relatively simple, the transmission of the user
as a prop to the children is not really a problem.
Let's say I want to make my navigation bar prettier, with a real logout menu displaying the user's name. I'll have to pass this user
to the Navigation
component.
<Navigation onLogout={onLogout} user={user} />
Moreover, if the <UserMenu>
component uses another component to display the user, I'll have to transmit my user again:
const UserMenu = ({ onLogout, user }) => {
<div>
<DisplayUser user={user} />
<UserSubMenu onLogout={onLogout} />
</div>;
};
The user
has been passed through 4 components before being displayed...
What about a more complex and/or heavier application? This can become very painful. It's one of the situations where it becomes legitimate to ask the question of the use of Redux!
However, there is now a straightforward solution to transmit data from one component to others that are deeper in the React tree: the React Context.
Passing The State Down Using React Context
The React.createContext
method generates two components:
const { Provider, Consumer } = React.createContext(defaultValue);
- a
Provider
responsible for distributing the data, - a
Consumer
that's able to read the provider data.
Let's go back to the three previous components.
// in App.js
import React, { Component } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import styled from 'styled-components';
import { decode } from 'jsonwebtoken';
...
const UserContext = React.createContext({
user: null,
onLogout: () => true,
});
export const UserConsumer = UserContext.Consumer;
const UserProvider = UserContext.Provider;
export class App extends Component {
...
render() {
const { user } = this.state;
return (
<UserProvider
value={{
user,
onLogout: this.handleLogout,
}}
>
<Router>
<Container>
<Header />
<Switch>
<PrivateRoute
exact
path="/"
render={({ location }) => (
<Home location={location} />
)}
/>
...
// in PrivateRoute.js
import React from "react";
import PropTypes from "prop-types";
import { Redirect, Route } from "react-router";
import { UserConsumer } from "./App";
const PrivateRouteWithoutContext = ({ user, ...rest }) =>
user ? (
<Route {...rest} />
) : (
<Redirect
to={{
pathname: "/login",
state: { from: rest.location },
}}
/>
);
PrivateRouteWithoutContext.propTypes = {
user: PropTypes.object,
};
export const PrivateRoute = props => {
return (
<UserConsumer>
{({ user }) => <PrivateRouteWithoutContext user={user} {...props} />}
</UserConsumer>
);
};
Consumer
uses the render prop pattern.// in components/Header.js
import React from "react";
import PropTypes from "prop-types";
import { UserConsumer } from "../App";
import { Navigation } from "./Navigation";
export const HeaderWithoutContext = ({ user, onLogout }) => (
<header>
<h1>JavaScript Playground: meetups</h1>
{user && <Navigation onLogout={onLogout} />}
</header>
);
HeaderWithoutContext.propTypes = {
user: PropTypes.object,
onLogout: PropTypes.func.isRequired,
};
export const Header = () => {
return (
<UserConsumer>
{({ user, onLogout }) => (
<HeaderWithoutContext user={user} onLogout={onLogout} />
)}
</UserConsumer>
);
};
React Context is a simple way to teleport data directly from a level N component of the application to any level N-x children component.
So, Redux or Not Redux ?
Redux becomes interesting as soon as a project reaches a certain level of complexity. However, it's rarely a good idea to prejudge the degree of complexity of your code! I prefer to keep things simple to say to myself: "Great! I'm going to make something complex" afterwards. It reminds me of a few years ago, when Symfony was systematically used to start a PHP project, while Silex made it much more comfortable and faster to get started.
Nevertheless, just like Symfony, using Redux can become a very wise choice. Using it at the beginning of the project is just a premature decision.
It's not really fresh news 😄
Twitter might track you and we would rather have your consent before loading this tweet.
Also, beyond these somewhat theoretical considerations, it seems that there are also beneficial effects to the fact of going away from Redux.
First, I focus more on React! By coding the second example in this post, I rediscovered the pleasure of building an application only from bricks of components: it's like playing Lego. The use of render prop allows code re-use throughout the project while maintaining this logic of nesting React components. It is a powerful pattern, less magical than the HOC. Furthermore, it will adapt to the possible implementation of Redux when the time comes. The proof of this is react-admin 2.0 which dissociate the UI part from the application logic, thanks to a render prop.
Finally, this seems the direction taken by the React team. With the new Context API, they offer the possibility to set up a global store easily shareable without adopting Redux.