Skip to content

Add support for offline/local first applications #10545

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 59 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
aaf7860
Add support for offline/local first applications
djhi Feb 24, 2025
2aa2cba
Ensure no existing tests are broken
djhi Feb 24, 2025
a1b2157
Try stabilizing tests
djhi Feb 24, 2025
9d66e05
Fix e2e tests
djhi Feb 25, 2025
fe15863
Introduce `addOfflineSupportToQueryClient`
djhi Apr 25, 2025
1abc446
Add documentation
djhi Apr 25, 2025
25ff38b
Trigger build
djhi Apr 25, 2025
9f83cab
Merge remote-tracking branch 'origin/next' into support-offline-mode
slax57 Apr 29, 2025
eae5aa6
fix merge issues
slax57 Apr 29, 2025
87e537a
Merge branch 'next' into support-offline-mode
djhi Apr 30, 2025
b0bc9a0
Cleanup documentation
djhi Apr 30, 2025
cf79b3b
Fix e2e tests
djhi Apr 30, 2025
78262e9
Merge branch 'next' into support-offline-mode
djhi May 6, 2025
6287a1c
Fix documentation
djhi May 6, 2025
7d3d2e2
Remove unnecessary ts-ignore
djhi May 6, 2025
fd94c6a
Apply suggestions from code review
djhi May 7, 2025
b87aed7
Apply review suggestions
djhi May 7, 2025
39ce253
Update useListContextWithProps
djhi May 7, 2025
2737f75
Improve post creation ux
djhi May 9, 2025
07121fd
Fix e2e tests
djhi May 12, 2025
233ce57
Add offline message to ReferenceField
djhi May 15, 2025
f88c509
Add offline support to list components
djhi May 15, 2025
8f3096b
Add offline support to reference components
djhi May 15, 2025
9f418bf
Add offline support to details components
djhi May 15, 2025
6778fd1
Fix tests and stories
djhi May 15, 2025
934fff4
Use Offline everywhere
djhi May 15, 2025
32af617
Add support for offline in Reference inputs
djhi May 15, 2025
02a957a
Add documentation
djhi May 15, 2025
4c71375
Fix documentation
djhi May 15, 2025
47cdf92
Improve EditView types
djhi May 15, 2025
466aeb1
Apply suggestions from code review
djhi May 19, 2025
72ff0b1
Fix exports
djhi May 19, 2025
6d3daeb
Fix list children handling of offline state
djhi May 20, 2025
c7b22d6
Fix detail views handling of offline state
djhi May 20, 2025
1be8e73
Fix reference inputs handling of offline state
djhi May 20, 2025
01a1a94
Fix ReferenceOneField
djhi May 20, 2025
10ae938
Improve ReferenceInput
djhi May 20, 2025
9849a2c
Correctly export Offline component
djhi May 20, 2025
4a3cb09
Avoid offline specific styles in details views
djhi May 20, 2025
84207e0
Rename isOnline hook to useIsOffline
djhi May 20, 2025
2e2e10c
Improve Offline component design for reference fields and inputs
djhi Jun 5, 2025
8452c4f
Make sure users know about pending operations
djhi Jun 6, 2025
a55d5ef
Improve mutation mode selector
djhi Jun 10, 2025
8384ca3
Improve documentation
djhi Jun 10, 2025
02dbe24
Improve notifications for offline mutations
djhi Jun 20, 2025
0ee8837
Merge branch 'next' into support-offline-mode
djhi Jun 25, 2025
b648d08
Fix delete mutations success message handling
djhi Jun 25, 2025
929d606
Fix ReferenceOneField empty case handling
djhi Jun 25, 2025
1154f17
Fix simple example dependencies
djhi Jun 26, 2025
5b7f83a
Fix JSDoc examples
djhi Jun 26, 2025
d114f96
Remove unnecessary CSS classes
djhi Jun 26, 2025
30a5ec0
Fix Offline types
djhi Jun 26, 2025
68d00a7
Fix LoadingIndicator
djhi Jun 26, 2025
ed0f0df
Fix SingleFieldList story
djhi Jun 26, 2025
d0586a1
Fix ReferenceInput offline detection
djhi Jun 26, 2025
b03dfb4
Fix reference fields and inputs
djhi Jun 26, 2025
dbd8dd1
Improve documentation
djhi Jun 26, 2025
b8b13c5
Fix yarn.lock
djhi Jun 26, 2025
a3b1da8
Document how to handle errors for resumed mutations
djhi Jun 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cypress/e2e/edit.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ describe('Edit Page', () => {
it('should redirect to list page after edit success', () => {
// For some unknown reason, the click on submit didn't work in cypress
// so we submit with enter
EditPostPage.setInputValue('input', 'title', 'Lorem Ipsum{enter}');
EditPostPage.setInputValue(
'input',
'title',
'Lorem Ipsum again{enter}'
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because react-query now persist queries and mutations for offline mode, the previous test now leaks into the second (e.g. this post has its title changed to Lorem Ipsum). I tried to configure testIsolation in Cypress but our version is probably too old

);
cy.url().should('match', /\/#\/posts$/);
});

Expand Down
9 changes: 5 additions & 4 deletions cypress/support/CreatePage.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ export default url => ({
inputs: `.ra-input`,
richTextInputError: '.create-page .ra-rich-text-input-error',
snackbar: 'div[role="alert"]',
submitButton: ".create-page div[role='toolbar'] button[type='submit']",
submitButton:
".create-page div[role='toolbar'] div:first-child button[type='submit']",
submitAndShowButton:
".create-page form div[role='toolbar'] button[type='button']:nth-child(2)",
".create-page form div[role='toolbar'] div:first-child button[type='button']:nth-child(2)",
submitAndAddButton:
".create-page form div[role='toolbar'] button[type='button']:nth-child(3)",
".create-page form div[role='toolbar'] div:first-child button[type='button']:nth-child(3)",
submitCommentable:
".create-page form div[role='toolbar'] button[type='button']:last-child",
".create-page form div[role='toolbar'] div:first-child button[type='button']:last-child",
descInput: '.ProseMirror',
tab: index => `.form-tab:nth-of-type(${index})`,
title: '#react-admin-title',
Expand Down
167 changes: 167 additions & 0 deletions docs/DataProviders.md
Original file line number Diff line number Diff line change
Expand Up @@ -888,3 +888,170 @@ export default App;
```

**Tip**: This example uses the function version of `setState` (`setDataProvider(() => dataProvider)`) instead of the more classic version (`setDataProvider(dataProvider)`). This is because some legacy Data Providers are actually functions, and `setState` would call them immediately on mount.

## Offline Support

React-admin supports offline/local-first applications. To enable this feature, install the following react-query packages:

```sh
yarn add @tanstack/react-query-persist-client @tanstack/query-sync-storage-persister
```

Then, register default functions for react-admin mutations on the `QueryClient` to enable resumable mutations (mutations triggered while offline). React-admin provides the `addOfflineSupportToQueryClient` function for this:

```ts
// in src/queryClient.ts
import { addOfflineSupportToQueryClient } from 'react-admin';
import { QueryClient } from '@tanstack/react-query';
import { dataProvider } from './dataProvider';

const baseQueryClient = new QueryClient();

export const queryClient = addOfflineSupportToQueryClient({
queryClient: baseQueryClient,
dataProvider,
resources: ['posts', 'comments'],
});
```

Then, wrap your `<Admin>` inside a [`<PersistQueryClientProvider>`](https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#persistqueryclientprovider):

{% raw %}
```tsx
// in src/App.tsx
import { Admin, Resource } from 'react-admin';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { queryClient } from './queryClient';
import { dataProvider } from './dataProvider';
import { posts } from './posts';
import { comments } from './comments';

const localStoragePersister = createSyncStoragePersister({
storage: window.localStorage,
});

export const App = () => (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: localStoragePersister }}
onSuccess={() => {
// resume mutations after initial restore from localStorage is successful
queryClient.resumePausedMutations();
}}
>
<Admin queryClient={queryClient} dataProvider={dataProvider}>
<Resource name="posts" {...posts} />
<Resource name="comments" {...comments} />
</Admin>
</PersistQueryClientProvider>
)
```
{% endraw %}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're missing a sentence to explain that the setup is done here - the rest is only for developers with custom methods.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

This is enough to make all the standard react-admin features support offline scenarios.

## Adding Offline Support To Custom Mutations

If you have [custom mutations](./Actions.md#calling-custom-methods) on your dataProvider, you can enable offline support for them too. For instance, if your `dataProvider` exposes a `banUser()` method:

```ts
const dataProvider = {
getList: /* ... */,
getOne: /* ... */,
getMany: /* ... */,
getManyReference: /* ... */,
create: /* ... */,
update: /* ... */,
updateMany: /* ... */,
delete: /* ... */,
deleteMany: /* ... */,
banUser: (userId: string) => {
return fetch(`/api/user/${userId}/ban`, { method: 'POST' })
.then(response => response.json());
},
}

export type MyDataProvider = DataProvider & {
banUser: (userId: string) => Promise<{ data: RaRecord }>
}
```

First, you must set a `mutationKey` for this mutation:

{% raw %}
```tsx
const BanUserButton = ({ userId }: { userId: string }) => {
const dataProvider = useDataProvider();
const { mutate, isPending } = useMutation({
mutationKey: 'banUser'
mutationFn: (userId) => dataProvider.banUser(userId)
});
return <Button label="Ban" onClick={() => mutate(userId)} disabled={isPending} />;
};
```
{% endraw %}

**Tip**: Note that unlike the [_Calling Custom Methods_ example](./Actions.md#calling-custom-methods), we passed `userId` to the `mutate` function. This is necessary so that React Query passes it too to the default function when resuming the mutation.

Then, register a default function for it:

```ts
// in src/queryClient.ts
import { addOfflineSupportToQueryClient } from 'react-admin';
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { dataProvider } from './dataProvider';

const baseQueryClient = new QueryClient();

export const queryClient = addOfflineSupportToQueryClient({
queryClient: baseQueryClient,
dataProvider,
resources: ['posts', 'comments'],
});

queryClient.setMutationDefaults('banUser', {
mutationFn: async (userId) => {
return dataProvider.banUser(userId);
},
});
```

## Handling Errors For Resumed Mutations

If you enabled offline support, users might trigger mutations while being actually offline. When they're back online, react-query will _resume_ those mutations and they might fail for other reasons (server side validation or errors). However, as users might have navigated away from the page that triggered the mutation, they won't see any notification.

To handle this scenario, you must register default `onError` side effects for all mutations (react-admin default ones or custom). If you want to leverage react-admin notifications, you can use a custom layout:

```tsx
// in src/Layout.tsx
export const MyLayout = ({ children }: { children: React.ReactNode }) => {
const queryClient = useQueryClient();
const notify = useNotify();

React.useEffect(() => {
const mutationKeyFilter = []; // An empty array targets all mutations
queryClient.setMutationDefaults([], {
onSettled(data, error) {
if (error) {
notify(error.message, { type: 'error' });
}
},
});
}, [queryClient, notify]);

return (
<Layout>
{children}
</Layout>
);
}
```

Note that this simple example will only show the error message as it was received. Users may not have the context to understand the error (what record or operation it relates to).
Here are some ideas for a better user experience:

- make sure your messages allow users to go to the pages related to the errors (you can leverage [custom notifications](./useNotify.md#custom-notification-content) for that)
- store the notifications somewhere (server side or not) and show them in a custom page with proper links, etc.
37 changes: 36 additions & 1 deletion docs/DataTable.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,13 @@ Each `<DataTable.Col>` defines one column of the table: its `source` (used for s
| `empty` | Optional | Element | `<Empty>` | The component to render when the list is empty. |
| `expand` | Optional | Element | | The component rendering the expand panel for each row. |
| `expandSingle` | Optional | Boolean | `false` | Whether to allow only one expanded row at a time. |
| `head` | Optional | Element | `<DataTable Header>` | The component rendering the table header. |
| `head` | Optional | Element | `<DataTable Header>` | The component rendering the table header. |
| `hiddenColumns` | Optional | Array | `[]` | The list of columns to hide by default. |
| `foot` | Optional | Element | | The component rendering the table footer. |
| `hover` | Optional | Boolean | `true` | Whether to highlight the row under the mouse. |
| `isRowExpandable` | Optional | Function | `() => true` | A function that returns whether a row is expandable. |
| `isRowSelectable` | Optional | Function | `() => true` | A function that returns whether a row is selectable. |
| `offline` | Optional | Element | `<Offline>` | The content rendered to render when data could not be fetched because of connectivity issues. |
| `rowClick` | Optional | mixed | | The action to trigger when the user clicks on a row. |
| `rowSx` | Optional | Function | | A function that returns the `sx` prop to apply to a row. |
| `size` | Optional | `'small'` or `'medium'` | `'small'` | The size of the table. |
Expand Down Expand Up @@ -754,6 +755,40 @@ export const PostList = () => (
```
{% endraw %}

## `offline`

It's possible that a `<DataTable>` will have no records to display because of connectivity issues. In that case, `<DataTable>` will display the following message:

> No connectivity. Could not fetch data.

You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key.

```tsx
const messages = {
ra: {
notification: {
offline: "No network. Data couldn't be fetched.",
}
}
}
```

If you need to go beyond text, pass a custom element as the `<DataTable offline>` prop:

```tsx
const Offline = () => (
<p>No network. Data couldn't be fetched.</p>
);

const BookList = () => (
<List>
<DataTable offline={<Offline />}>
...
</DataTable>
</List>
);
```

## `rowClick`

By default, `<DataTable>` uses the current [resource definition](https://marmelab.com/react-admin/Resource.html) to determine what to do when the user clicks on a row. If the resource has a `show` page, a row click redirects to the Show view. If the resource has an `edit` page, a row click redirects to the Edit view. Otherwise, the row is not clickable.
Expand Down
35 changes: 35 additions & 0 deletions docs/Datagrid.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Both are [Enterprise Edition](https://react-admin-ee.marmelab.com) components.
| `hover` | Optional | Boolean | `true` | Whether to highlight the row under the mouse. |
| `isRowExpandable` | Optional | Function | `() => true` | A function that returns whether a row is expandable. |
| `isRowSelectable` | Optional | Function | `() => true` | A function that returns whether a row is selectable. |
| `offline` | Optional | Element | `<Offline>` | The content rendered to render when data could not be fetched because of connectivity issues. |
| `optimized` | Optional | Boolean | `false` | Whether to optimize the rendering of the table. |
| `rowClick` | Optional | mixed | | The action to trigger when the user clicks on a row. |
| `rowStyle` | Optional | Function | | A function that returns the style to apply to a row. |
Expand Down Expand Up @@ -642,6 +643,40 @@ export const PostList = () => (
```
{% endraw %}

## `offline`

It's possible that a `<Datagrid>` will have no records to display because of connectivity issues. In that case, `<Datagrid>` will display the following message:

> No connectivity. Could not fetch data.

You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key.

```tsx
const messages = {
ra: {
notification: {
offline: "No network. Data couldn't be fetched.",
}
}
}
```

If you need to go beyond text, pass a custom element as the `<Datagrid offline>` prop:

```tsx
const CustomOffline = () => <div>No network. Data couldn't be fetched.</div>;

const PostList = () => (
<List>
<Datagrid offline={<CustomOffline />}>
<TextField source="id" />
<TextField source="title" />
<TextField source="views" />
</Datagrid>
</List>
);
```

## `optimized`

When displaying large pages of data, you might experience some performance issues.
Expand Down
69 changes: 52 additions & 17 deletions docs/Edit.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,22 +62,25 @@ export default App;

You can customize the `<Edit>` component using the following props:

* [`actions`](#actions): override the actions toolbar with a custom component
* [`aside`](#aside): component to render aside to the main content
* `children`: the components that renders the form
* `className`: passed to the root component
* [`component`](#component): override the root component
* [`disableAuthentication`](#disableauthentication): disable the authentication check
* [`emptyWhileLoading`](#emptywhileloading): Set to `true` to return `null` while the edit is loading.
* [`id`](#id): the id of the record to edit
* [`mutationMode`](#mutationmode): switch to optimistic or pessimistic mutations (undoable by default)
* [`mutationOptions`](#mutationoptions): options for the `dataProvider.update()` call
* [`queryOptions`](#queryoptions): options for the `dataProvider.getOne()` call
* [`redirect`](#redirect): change the redirect location after successful creation
* [`resource`](#resource): override the name of the resource to create
* [`sx`](#sx-css-api): Override the styles
* [`title`](#title): override the page title
* [`transform`](#transform): transform the form data before calling `dataProvider.update()`
| Prop | Required | Type | Default | Description |
| ----------------------- | -------- | ------------------------------------- | --------------------- | ------------------------------------------------------------- |
| `actions` | | `ReactNode` | | override the actions toolbar with a custom component |
| `aside` | | `ReactNode` | | component to render aside to the main content |
| `children` | | `ReactNode` | | The components that renders the form |
| `className` | | `string` | | passed to the root component |
| `component` | | `Component` | | override the root component |
| `disableAuthentication` | | `boolean` | | disable the authentication check |
| `emptyWhileLoading` | | `boolean` | | Set to `true` to return `null` while the edit is loading. |
| `id` | | `string | number` | | the id of the record to edit |
| `mutationMode` | | `pessimistic | optimistic | undoable` | | switch to optimistic or pessimistic mutations (undoable by default) |
| `mutationOptions` | | `object` | | options for the `dataProvider.update()` call |
| `offline` | | `ReactNode` | `<Offline>` | The content rendered to render when data could not be fetched because of connectivity issues |
| `queryOptions` | | `object` | | options for the `dataProvider.getOne()` call |
| `redirect` | | `string | Function | false` | | change the redirect location after successful creation |
| `resource` | | `string` | | override the name of the resource to create |
| `sx` | | `object` | | Override the styles |
| `title` | | `ReactNode` | | override the page title |
| `transform` | | `Function` | | transform the form data before calling `dataProvider.update()` |

## `actions`

Expand Down Expand Up @@ -490,6 +493,38 @@ The default `onError` function is:

**Tip**: If you want to have different failure side effects based on the button clicked by the user, you can set the `mutationOptions` prop on the `<SaveButton>` component, too.

## `offline`

It's possible that a `<Edit>` will have no records to display because of connectivity issues. In that case, `<Edit>` will display the following message:

> No connectivity. Could not fetch data.

You can customize this message via react-admin's [translation system](./Translation.md), by setting a custom translation for the `ra.notification.offline` key.

```tsx
const messages = {
ra: {
notification: {
offline: "No network. Data couldn't be fetched.",
}
}
}
```

If you need to go beyond text, pass a custom element as the `<Edit offline>` prop:

```tsx
const Offline = () => (
<p>No network. Data couldn't be fetched.</p>
);

const BookEdit = () => (
<Edit offline={<Offline />}>
...
</Edit>
);
```

## `queryOptions`

`<Edit>` calls `dataProvider.getOne()` on mount via react-query's `useQuery` hook. You can customize the options you pass to this hook by setting the `queryOptions` prop.
Expand All @@ -500,7 +535,7 @@ This can be useful e.g. to pass [a custom `meta`](./Actions.md#meta-parameter) t
```jsx
import { Edit, SimpleForm } from 'react-admin';

export const PostShow = () => (
export const PostEdit = () => (
<Edit queryOptions={{ meta: { foo: 'bar' } }}>
<SimpleForm>
...
Expand Down
Loading
Loading