React-admin V4: Goodbye, Redux!

François Zaninotto
François ZaninottoApril 08, 2022
#react#react-admin#tutorial

The most important difference between react-admin v3 and v4 lies under the hood. React-admin v4 no longer uses Redux for state management - without changing the developer API. Let's see that in detail.

The States Of React-admin

React-admin needs to use state for various purposes. We used Redux for all purposes in v3. In v4, we no longer need it.

v3v4
Data fetchingReduxreact-query
Shared stateReduxReact context
User preferencesReduxThe Store

When we first built react-admin (back in 2016), Redux was the go-to state management library. Inspired by Flux, encouraged by Facebook, more performant than React state, it was an ideal solution.

But over the years, Redux has shown many limitations in the context of react-admin:

  • Adding new elements to the store requires a lot of boilerplate code (a reducer function, combineReducer, etc)
  • Changing the store shape at runtime is challenging, and leads to hard-to-maintain code
  • Testing requires mocking the store, the reducers, the actions
  • Integrating react-admin into an existing app with its own store is a pain, as there can be only one store per app
  • A central store means every action triggers all reducers, and this causes performance problems

Besides, Redux' creator Dan Abramov explained that Redux is probably not the best option:

If you feel pressured to do things “the Redux way”, it may be a sign that you or your teammates are taking it too seriously. It’s just one of the tools in your toolbox, an experiment gone wild.

This led us to reconsider Redux for react-admin v4. Little by little, we replaced it with better solutions - until it was no longer necessary.

Data Fetching Is Now Powered By React-Query

React-admin exposes data fetching hooks (like useGetOne) to interact with the dataProvider conveniently.

// v3 syntax
const { data, loading, error } = useGetOne('posts', { id: 1 });

Under the hook, these hooks used Redux' dispatch() and useSelector, together with a sophisticated store structure to trigger async queries, keep a cache of the already fetched data, and provide the 'stale while revalidate' and 'optimistic rendering' features.

This worked well in the general case, but caused nasty bugs in several corner-case - the most well-known being an empty screen when revisiting an open session. Besides, developers needed to add a <Resource> for every API endpoint - even though there was no screen for a given resource.

It turns out that data fetching is now a solved problem in the React ecosystem. Third-party libraries like apollo, swr and react-query provide simple and powerful APIs that do the same thing we did with Redux - only in a better way.

So switching to react-query was a no-brainer. The impact on the developer API is minimal - just replace loading with isLoading:

// v4 syntax
const { data, isLoading, error } = useGetOne('posts', { id: 1 });

Under the hood, this hook now uses react-query's useQuery hook:

const { data, isLoading, error } = useQuery(['posts', { id: 1 }], () =>
    dataProvider.getOne('posts', { id: 1 }).then(({ data }) => data),
);

React-query provides a lot of benefits over our custom-made data fetching reducers:

Support for meta parameters: React-query allows to pass additional parameters to each query, e.g.:

const { data, isLoading, error } = useGetOne('books', {
    id,
    meta: { _embed: 'authors' },
});

Support for partial pagination: Sometimes it's not possible to return a total in the dataProvider.getList() response (e.g. because counting this total is too expensive). React-admin v4 now accepts a pageInfo object in the dataProvider.getList() response, and the pagination component can use it instead of total:

dataProvider
    .getList('comments', {
        pagination: { page: 1, perPage: 6 },
        sort: { field: 'created_at', order: 'DESC' },
    })
    .then(response => console.log(response));
// {
//     data: [
//         { id: 126, body: "allo?", author_id: 12 },
//         { id: 127, body: "bien le bonjour", author_id: 12 },
//         { id: 124, body: "good day sunshine", author_id: 45 },
//         { id: 123, body: "hello, world", author_id: 67 },
//         { id: 125, body: "howdy partner", author_id: 89 },
//         { id: 138, body: "nice sweater!", author_id: 78 },
//     ],
//     pageInfo: {
//         hasPreviousPage: false,
//         hasNextPage: true,
//     }
// }

Support for infinite pagination: React-query allows Infinite queries out of the box, i.e. rendering lists that can additively "load more" data onto an existing set of data or "infinite scroll".

Easier relationship management: To call a custom query or mutation on a given resource, you no longer have to define a <Resource> for it.

const App = () => (
    <Admin dataProvider={dataProvider}>
        <Resource name="posts" {...posts} />
        <Resource name="comments" {...comments} />
-       <Resource name="tags" />
    </Admin>
)

Developer tools for data fetching: React-query comes with its own DevTools, which prove very handy to visualize the queries made by a page.

React-Query

Window focus & network status refetching, Auto garbage collection, scroll recovery, polling... React-query's feature set is impressive.

Great documentation: React-query's documentation is complete, up to date, usable, full of examples, and a pleasure to read.

React-admin offers two features that aren't provided natively by react-query: Optimistic rendering and undo. We've reimplemented them on top of react-query, so there is no difference in features between v3 and v4.

Overall, react-query is a huge win for developers. And for the react-admin core team, using react-query for data fetching removed lots of hard-to-maintain code.

Shared State Now Uses React Context

One common use of Redux is to store a piece of state shared by several components. For instance, in react-admin, the sidebar open/closed state is shared between the AppBar (to toggle the hamburger icon) and the sidebar itself (to change its width).

As we used Redux for data fetching, it felt natural to use it for the shared state, too. But in 2018, the React team introduced the official Context API, and in 2019 they added hooks to read that context from functional components.

While refactoring old code for react-admin v4, we felt that the Context API was enough to store the shared state. Since the developer API mostly hid the Redux store behind hooks, this change is completely backward compatible.

For instance, we used Redux to store the notifications. To show a notification, developers had to use useNotify(), which under the hood dispatched a Redux action. In v4, useNotify() interacts with a new NotificationContext, but the developer API remains the same.

import { useNotify } from 'react-admin';

const NotifyButton = () => {
    // same API in v3 and v4
    const notify = useNotify();
    const handleClick = () => {
        notify(`Comment approved`, { type: 'success' });
    };
    return <button onClick={handleClick}>Notify</button>;
};

Redux has one merit over the Context API: its selector API avoids a lot of rerenders when a component only depends on parts of the shared state. Since context selectors aren't yet available in React, we solved that performance problem by splitting the shared state into several small pieces. So instead of one big "Store", react-admin now has many Contexts:

  • BasenameContext
  • NotificationContext
  • ResourceDefinitionContext
  • SaveContext
  • FormGroupContext
  • ChoicesContext
  • I18nContext
  • UserMenuContext
  • DatagridContext_ ...

This also allows a better organization of the code and a better separation of concerns. With that change, the react-admin code is more robust, easier to maintain, and still super fast.

User Preferences Use a Custom Store

Redux was a great choice for storing user preferences. Coupled with redux-persist, it allowed persisting the user preferences in local storage between page reloads. We used it for:

  • the sidebar open/closed state
  • the language
  • the theme
  • the list filters & sort order
  • the saved queries
  • custom user preferences

But using Redux and redux-persist just for that felt overkill. Seen from a developer's point of view, user preferences are just a form of global React state with a persistence layer:

const [theme, setTheme] = usePreferences('theme', defaultTheme);

The global state can be modeled with React.useState and React.useContext with much less boilerplate than Redux. As for the persistence layer, synchronizing with localStorage doesn't justify 10kB of code.

We decided to craft our own persistent store, with a narrower feature set than Redux, just for storing preferences. This became "The Store", which I already wrote about in a previous post.

Its API is simple and similar to React.useState:

import { useStore } from 'react-admin';
import { Button, Popover } from '@mui/material';

const HelpButton = () => {
    const [helpOpen, setHelpOpen] = useStore('help.open', false);
    return (
        <>
            <Button onClick={() => setHelpOpen(v => !v)}>
                {helpOpen ? 'Hide' : 'Show'} help
            </Button>
            <Popover open={helpOpen} onClose={() => setHelpOpen(false)}>
                French
            </Popover>
        </>
    );
};

And with user preferences out of the Redux store, there was nothing left.

Why Not Use Zustand/Recoil/Jotai/Any Other State Management Library?

Redux' shortcomings inspired a lot of alternatives. We considered each of them when we decided to remove Redux for shared state and user preferences.

These libraries require additional knowledge (e.g. atoms, setter functions, etc.) and a bit of tweaking to support localStorage. Although they are a better fit than Redux for react-admin's needs, they are still overkill and heavier than what we really need.

  • Jotai: The closest to what we need, but they do a lot of efforts to avoid string keys with atoms, just to add them back when addressing localStorage. Besides, they address a lot of needs we don't have (derived atoms, async read, async actions), and that translates to 3.2kb of code.
  • Zustand: Super small, but relies on selector functions and a centralized store - too much ceremony for a key/value store.
  • Recoil: Requires a root component, requires selectors and atoms, and supports things we don't need (async requests). Super heavy (22kB)

All in all, a custom store with a reduced feature set was the best choice.

Redux Is Gone

So react-admin apps no longer need Redux or redux-saga. You will need to update your code if it contains any of the following keywords:

  • createAdminStore
  • customReducers
  • customSagas
  • initialState
  • useSelector
  • useDispatch

The react-admin 4.0 migration guide explains how to update your code.

Note that you can still use Redux in your application code - you just won't need to mix your store with the react-admin store.

-import { createAdminStore, Admin } from 'react-admin';
+import { Admin } from 'react-admin';
+import { createStore, combineReducers } from 'redux';
-import { createHashHistory } from 'history';
import { Provider } from 'react-redux';

import { authProvider } from './authProvider';
import { dataProvider } from './dataProvider';

-const history = createHashHistory();

const App = () => (
    <Provider
-       store={createAdminStore({
-           authProvider,
-           dataProvider,
-           history,
-           customReducers,
-       })}
+       store={createStore(combineReducers(customReducers))}
    >
        <Admin
            authProvider={authProvider}
            dataProvider={dataProvider}
-           history={history}
            title="My Admin"
        >
            ...
        </Admin>
    </Provider>
);

This will also drastically simplify your unit tests. If you were using renderWithRedux, your tests can be updated to use react-testing-library's render instead - without the need to setup an initial Redux state:

import React from 'react';
-import { renderWithRedux } from 'ra-test';
+import { AdminContext } from 'react-admin';
import { render, screen } from '@testing-library/react';

import MyComponent from './MyComponent';

test('<MyComponent>', async () => {
-   renderWithRedux(
-       <MyComponent />,
-       { admin: { resources: { posts: { data: {} } } } },
-   );
+   render(
+       <AdminContext>
+           <MyComponent />
+       </AdminContext>
+   );
    const items = await screen.findAllByText(/Item #[0-9]: /)
    expect(items).toHaveLength(10)
})

Conclusion

The removal of Redux doesn't change much to the developer API. The hooks that powered data fetching, shared state, and preferences still work the same as in v3. The user experience isn't affected either - the runtime performance still remains the same.

But you will feel a big difference when dealing with complex features, and when debugging async interactions. No more time will be wasted trying to understand the intricate structure of the Redux store and reducer logic. You will be able to focus on the business logic.

In our experience, react-admin v4 requires 30% less code than v3, is less invasive, more intuitive, and is easier to learn. You won't have to look for "the react-admin way" of doing things. Because the react-admin way is now the React way. You're going to love it!

Did you like this article? Share it!