Start Jotai Migration (#17893)

## Recoil → Jotai progressive migration: infrastructure +
ChipFieldDisplay

### Benchmark

In the beginning, there was no hope:
<img width="1180" height="948" alt="image"
src="https://github.com/user-attachments/assets/f8635991-52e6-4958-8240-6ba7214132b2"
/>

Then the hope was reborn
<img width="2070" height="948" alt="image"
src="https://github.com/user-attachments/assets/be1182b9-1c8d-4fdc-ab4c-1484ad74449d"
/>



### Approach

We introduce a **V2 state management layer** backed by Jotai that
mirrors the existing Recoil API, enabling component-by-component
migration without a big-bang rewrite.

#### V2 API (Jotai-backed, Recoil-ergonomic)

- `createStateV2` / `createFamilyStateV2` — drop-in replacements for
`createState` / `createFamilyState`, returning wrapper types over Jotai
atoms
- `useRecoilValueV2`, `useRecoilStateV2`, `useFamilyRecoilValueV2`, etc.
— thin wrappers around Jotai's `useAtomValue` / `useAtom` / `useSetAtom`
- A shared `jotaiStore` (via `createStore()`) passed to a
`<JotaiProvider>` wrapping `<RecoilRoot>`, also accessible imperatively
for dual-writes

#### Dual-write bridge for progressive migration

For state shared between migrated and non-migrated components, we use
**dual-write**: writers update both the Recoil atom and the Jotai V2
atom (via `jotaiStore.set()`). This avoids sync components or extra
subscriptions.

Write sites updated: `useUpsertRecordsInStore`, `useSetRecordTableData`,
`ListenRecordUpdatesEffect`, `RecordShowEffect`,
`useLoadRecordIndexStates`, `useUpdateObjectViewOptions`.

#### First migration: ChipFieldDisplay render path

- `useChipFieldDisplay` → reads `recordStoreFamilyStateV2` via
`useFamilyRecoilValueV2` (was `useRecoilValue(recordStoreFamilyState)`)
- `RecordChip` → reads `recordIndexOpenRecordInStateV2` via
`useRecoilValueV2` (was `useRecoilValue(recordIndexOpenRecordInState)`)
- `Avatar` (twenty-ui) and event handlers (`useOpenRecordInCommandMenu`)
left on Recoil — not on the render path / in a different package

#### Pattern for migrating additional state

1. Create V2 atom: `createStateV2` or `createFamilyStateV2`
2. Add `jotaiStore.set(v2Atom, value)` at each write site
3. Switch readers to `useRecoilValueV2(v2Atom)`
4. Once all readers are migrated, remove the Recoil atom and dual-writes

#### Why not jotai-recoil-adapter?

Evaluated
[jotai-recoil-adapter](https://github.com/clockelliptic/jotai-recoil-adapter)
— not production-ready (21 open issues, no React 19, forces providerless
mode, missing types). We built a purpose-built thin layer instead.
This commit is contained in:
Charles Bochet
2026-02-12 16:05:38 +01:00
committed by GitHub
parent 08b962b0d2
commit d2f8352cb8
87 changed files with 1086 additions and 241 deletions
@@ -1,6 +1,7 @@
import { ApolloProvider } from '@apollo/client';
import { loadDevMessages } from '@apollo/client/dev';
import { type Decorator } from '@storybook/react-vite';
import { Provider as JotaiProvider } from 'jotai';
import { HelmetProvider } from 'react-helmet-async';
import {
createMemoryRouter,
@@ -15,6 +16,7 @@ import { ClientConfigProviderEffect } from '@/client-config/components/ClientCon
import { ApolloCoreClientMockedProvider } from '@/object-metadata/hooks/__mocks__/ApolloCoreClientMockedProvider';
import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { MetadataProviderEffect } from '@/users/components/MetadataProviderEffect';
import { ClientConfigProvider } from '~/modules/client-config/components/ClientConfigProvider';
import { UserProvider } from '~/modules/users/components/UserProvider';
@@ -75,42 +77,44 @@ await dynamicActivate(SOURCE_LOCALE);
const Providers = () => {
return (
<RecoilRoot>
<SnackBarComponentInstanceContext.Provider
value={{ instanceId: 'snack-bar-manager' }}
>
<RecoilDebugObserverEffect />
<ApolloProvider client={mockedApolloClient}>
<I18nProvider i18n={i18n}>
<ApolloStorybookDevLogEffect />
<ClientConfigProviderEffect />
<ClientConfigProvider>
<MetadataProviderEffect />
<WorkspaceProviderEffect />
<UserProvider>
<ApolloCoreClientMockedProvider>
<ObjectMetadataItemsLoadEffect />
<ObjectMetadataItemsProvider>
<FullHeightStorybookLayout>
<HelmetProvider>
<IconsProvider>
<PrefetchDataProvider>
<RecordComponentInstanceContextsWrapper componentInstanceId="storybook-test-record">
<Outlet />
</RecordComponentInstanceContextsWrapper>
</PrefetchDataProvider>
</IconsProvider>
</HelmetProvider>
</FullHeightStorybookLayout>
</ObjectMetadataItemsProvider>
<MainContextStoreProvider />
</ApolloCoreClientMockedProvider>
</UserProvider>
</ClientConfigProvider>
</I18nProvider>
</ApolloProvider>
</SnackBarComponentInstanceContext.Provider>
</RecoilRoot>
<JotaiProvider store={jotaiStore}>
<RecoilRoot>
<SnackBarComponentInstanceContext.Provider
value={{ instanceId: 'snack-bar-manager' }}
>
<RecoilDebugObserverEffect />
<ApolloProvider client={mockedApolloClient}>
<I18nProvider i18n={i18n}>
<ApolloStorybookDevLogEffect />
<ClientConfigProviderEffect />
<ClientConfigProvider>
<MetadataProviderEffect />
<WorkspaceProviderEffect />
<UserProvider>
<ApolloCoreClientMockedProvider>
<ObjectMetadataItemsLoadEffect />
<ObjectMetadataItemsProvider>
<FullHeightStorybookLayout>
<HelmetProvider>
<IconsProvider>
<PrefetchDataProvider>
<RecordComponentInstanceContextsWrapper componentInstanceId="storybook-test-record">
<Outlet />
</RecordComponentInstanceContextsWrapper>
</PrefetchDataProvider>
</IconsProvider>
</HelmetProvider>
</FullHeightStorybookLayout>
</ObjectMetadataItemsProvider>
<MainContextStoreProvider />
</ApolloCoreClientMockedProvider>
</UserProvider>
</ClientConfigProvider>
</I18nProvider>
</ApolloProvider>
</SnackBarComponentInstanceContext.Provider>
</RecoilRoot>
</JotaiProvider>
);
};
@@ -1,8 +1,10 @@
import { ApolloProvider } from '@apollo/client';
import { type Decorator } from '@storybook/react-vite';
import { Provider as JotaiProvider } from 'jotai';
import { RecoilRoot } from 'recoil';
import { ApolloCoreClientMockedProvider } from '@/object-metadata/hooks/__mocks__/ApolloCoreClientMockedProvider';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { mockedApolloClient } from '~/testing/mockedApolloClient';
@@ -10,12 +12,14 @@ export const RootDecorator: Decorator = (Story, context) => {
const { parameters } = context;
return (
<RecoilRoot initializeState={parameters.initializeState}>
<ApolloProvider client={mockedApolloClient}>
<ApolloCoreClientMockedProvider>
<Story />
</ApolloCoreClientMockedProvider>
</ApolloProvider>
</RecoilRoot>
<JotaiProvider store={jotaiStore}>
<RecoilRoot initializeState={parameters.initializeState}>
<ApolloProvider client={mockedApolloClient}>
<ApolloCoreClientMockedProvider>
<Story />
</ApolloCoreClientMockedProvider>
</ApolloProvider>
</RecoilRoot>
</JotaiProvider>
);
};
@@ -1,4 +1,5 @@
import { MockedProvider, type MockedResponse } from '@apollo/client/testing';
import { Provider as JotaiProvider } from 'jotai';
import { type ReactNode } from 'react';
import { RecoilRoot, type MutableSnapshot } from 'recoil';
@@ -7,6 +8,7 @@ import { ContextStoreComponentInstanceContext } from '@/context-store/states/con
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { SnackBarComponentInstanceContext } from '@/ui/feedback/snack-bar-manager/contexts/SnackBarComponentInstanceContext';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { type InMemoryCache } from '@apollo/client';
import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter';
@@ -26,28 +28,30 @@ export const getJestMetadataAndApolloMocksWrapper = ({
objectMetadataItems?: ObjectMetadataItem[];
}) => {
return ({ children }: { children: ReactNode }) => (
<RecoilRoot initializeState={onInitializeRecoilSnapshot}>
<SnackBarComponentInstanceContext.Provider
value={{ instanceId: 'snack-bar-manager' }}
>
<MockedProvider mocks={apolloMocks} addTypename={false} cache={cache}>
<RecordComponentInstanceContextsWrapper componentInstanceId="instanceId">
<ViewComponentInstanceContext.Provider
value={{ instanceId: 'instanceId' }}
>
<JestObjectMetadataItemSetter
objectMetadataItems={objectMetadataItems}
<JotaiProvider store={jotaiStore}>
<RecoilRoot initializeState={onInitializeRecoilSnapshot}>
<SnackBarComponentInstanceContext.Provider
value={{ instanceId: 'snack-bar-manager' }}
>
<MockedProvider mocks={apolloMocks} addTypename={false} cache={cache}>
<RecordComponentInstanceContextsWrapper componentInstanceId="instanceId">
<ViewComponentInstanceContext.Provider
value={{ instanceId: 'instanceId' }}
>
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId: 'instanceId' }}
<JestObjectMetadataItemSetter
objectMetadataItems={objectMetadataItems}
>
<JestContextStoreSetter>{children}</JestContextStoreSetter>
</ContextStoreComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</ViewComponentInstanceContext.Provider>
</RecordComponentInstanceContextsWrapper>
</MockedProvider>
</SnackBarComponentInstanceContext.Provider>
</RecoilRoot>
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId: 'instanceId' }}
>
<JestContextStoreSetter>{children}</JestContextStoreSetter>
</ContextStoreComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</ViewComponentInstanceContext.Provider>
</RecordComponentInstanceContextsWrapper>
</MockedProvider>
</SnackBarComponentInstanceContext.Provider>
</RecoilRoot>
</JotaiProvider>
);
};