Skip to content

Commit 169c56f

Browse files
committed
refactor(tokensList): replace props drilling with Jotai atom
- Move token list view state to `tokenListViewAtom` using Jotai - Remove prop drilling through `SelectTokenModal` -> `TokensContent` -> `TokensVirtualList` - Extract `SelectTokenModalShell` to reduce LOC in `SelectTokenModal/index.tsx` - Implement `useHydrateAtoms` + `useLayoutEffect` for flicker-free state sync - Add `useResetTokenListViewState` for clean unmounts - Remove TODO from `types.ts`
1 parent a39b058 commit 169c56f

File tree

11 files changed

+279
-237
lines changed

11 files changed

+279
-237
lines changed

apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,17 @@
11
import { ReactNode, useCallback, useEffect, useMemo } from 'react'
22

3-
import { TokenWithLogo } from '@cowprotocol/common-const'
43
import { doesTokenMatchSymbolOrAddress } from '@cowprotocol/common-utils'
54
import { getTokenSearchFilter, TokenSearchResponse, useSearchToken } from '@cowprotocol/tokens'
65

76
import { useAddTokenImportCallback } from '../../hooks/useAddTokenImportCallback'
7+
import { useTokenListViewState } from '../../hooks/useTokenListViewState'
88
import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
99
import { CommonListContainer } from '../../pure/commonElements'
1010
import { TokenSearchContent } from '../../pure/TokenSearchContent'
11-
import { SelectTokenContext } from '../../types'
1211

13-
export interface TokenSearchResultsProps {
14-
searchInput: string
15-
selectTokenContext: SelectTokenContext
16-
areTokensFromBridge: boolean
17-
allTokens: TokenWithLogo[]
18-
}
12+
export function TokenSearchResults(): ReactNode {
13+
const { searchInput, selectTokenContext, areTokensFromBridge, allTokens } = useTokenListViewState()
1914

20-
export function TokenSearchResults({
21-
searchInput,
22-
selectTokenContext,
23-
areTokensFromBridge,
24-
allTokens,
25-
}: TokenSearchResultsProps): ReactNode {
2615
const { onSelectToken, onTokenListItemClick } = selectTokenContext
2716

2817
// Do not make search when tokens are from bridge
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { useSetAtom } from 'jotai'
2+
import { useCallback } from 'react'
3+
4+
import { tokenListViewAtom, DEFAULT_TOKEN_LIST_VIEW_STATE } from '../state/tokenListViewAtom'
5+
6+
type ResetTokenListViewState = () => void
7+
8+
export function useResetTokenListViewState(): ResetTokenListViewState {
9+
const setTokenListView = useSetAtom(tokenListViewAtom)
10+
11+
return useCallback((): void => {
12+
setTokenListView(DEFAULT_TOKEN_LIST_VIEW_STATE) // Full replacement, not partial merge
13+
}, [setTokenListView])
14+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { useAtomValue } from 'jotai'
2+
3+
import { tokenListViewAtom, TokenListViewState } from '../state/tokenListViewAtom'
4+
5+
export function useTokenListViewState(): TokenListViewState {
6+
return useAtomValue(tokenListViewAtom)
7+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { useSetAtom } from 'jotai'
2+
import { SetStateAction } from 'jotai/vanilla'
3+
4+
import { updateTokenListViewAtom, TokenListViewState } from '../state/tokenListViewAtom'
5+
6+
type UpdateTokenListViewState = (update: SetStateAction<Partial<TokenListViewState>>) => void
7+
8+
export function useUpdateTokenListViewState(): UpdateTokenListViewState {
9+
return useSetAtom(updateTokenListViewAtom)
10+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { ComponentProps, ReactNode } from 'react'
2+
3+
import { SearchInput } from '@cowprotocol/ui'
4+
5+
import { t } from '@lingui/core/macro'
6+
7+
import { TitleBarActions } from './helpers'
8+
import { MobileChainSelector } from './MobileChainSelector'
9+
import * as styledEl from './styled'
10+
11+
export interface SelectTokenModalShellProps {
12+
children: ReactNode
13+
hasChainPanel: boolean
14+
isFullScreenMobile?: boolean
15+
title: string
16+
showManageButton: boolean
17+
onDismiss(): void
18+
onOpenManageWidget: () => void
19+
searchValue: string
20+
onSearchChange(value: string): void
21+
onSearchEnter?: () => void
22+
mobileChainSelector?: ComponentProps<typeof MobileChainSelector>
23+
sideContent?: ReactNode
24+
}
25+
26+
export function SelectTokenModalShell({
27+
children,
28+
hasChainPanel,
29+
isFullScreenMobile,
30+
title,
31+
showManageButton,
32+
onDismiss,
33+
onOpenManageWidget,
34+
searchValue,
35+
onSearchChange,
36+
onSearchEnter,
37+
mobileChainSelector,
38+
sideContent,
39+
}: SelectTokenModalShellProps): ReactNode {
40+
return (
41+
<styledEl.Wrapper $hasChainPanel={hasChainPanel} $isFullScreen={isFullScreenMobile}>
42+
<TitleBarActions
43+
showManageButton={showManageButton}
44+
onDismiss={onDismiss}
45+
onOpenManageWidget={onOpenManageWidget}
46+
title={title}
47+
/>
48+
<styledEl.SearchRow>
49+
<styledEl.SearchInputWrapper>
50+
<SearchInput
51+
id="token-search-input"
52+
value={searchValue}
53+
onKeyDown={(event) => {
54+
if (event.key === 'Enter') {
55+
onSearchEnter?.()
56+
}
57+
}}
58+
onChange={(event) => onSearchChange(event.target.value)}
59+
placeholder={t`Search name or paste address...`}
60+
/>
61+
</styledEl.SearchInputWrapper>
62+
</styledEl.SearchRow>
63+
{mobileChainSelector ? <MobileChainSelector {...mobileChainSelector} /> : null}
64+
<styledEl.Body>
65+
<styledEl.TokenColumn>{children}</styledEl.TokenColumn>
66+
{sideContent}
67+
</styledEl.Body>
68+
</styledEl.Wrapper>
69+
)
70+
}

apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { ReactNode, useMemo, useState } from 'react'
1+
import { ComponentProps, ReactNode, useMemo, useState } from 'react'
22

33
import { BackButton } from '@cowprotocol/ui'
44

55
import { t } from '@lingui/core/macro'
66

77
import { SettingsIcon } from 'modules/trade/pure/Settings'
88

9+
import { MobileChainSelector } from './MobileChainSelector'
910
import * as styledEl from './styled'
1011

1112
import { SelectTokenContext } from '../../types'
@@ -54,6 +55,38 @@ export function useTokenSearchInput(defaultInputValue = ''): [string, (value: st
5455
return [inputValue, setInputValue, inputValue.trim()]
5556
}
5657

58+
export function getMobileChainSelectorConfig({
59+
showChainPanel,
60+
mobileChainsState,
61+
mobileChainsLabel,
62+
onSelectChain,
63+
onOpenMobileChainPanel,
64+
}: {
65+
showChainPanel: boolean
66+
mobileChainsState: SelectTokenModalProps['mobileChainsState']
67+
mobileChainsLabel: SelectTokenModalProps['mobileChainsLabel']
68+
onSelectChain?: SelectTokenModalProps['onSelectChain']
69+
onOpenMobileChainPanel?: SelectTokenModalProps['onOpenMobileChainPanel']
70+
}): ComponentProps<typeof MobileChainSelector> | undefined {
71+
const canRender =
72+
!showChainPanel &&
73+
mobileChainsState &&
74+
onSelectChain &&
75+
onOpenMobileChainPanel &&
76+
(mobileChainsState.chains?.length ?? 0) > 0
77+
78+
if (!canRender) {
79+
return undefined
80+
}
81+
82+
return {
83+
chainsState: mobileChainsState,
84+
label: mobileChainsLabel,
85+
onSelectChain,
86+
onOpenPanel: onOpenMobileChainPanel,
87+
}
88+
}
89+
5790
interface TitleBarActionsProps {
5891
showManageButton: boolean
5992
onDismiss(): void

apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx

Lines changed: 55 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
import { ComponentProps, ReactNode, useMemo } from 'react'
2-
3-
import { SearchInput } from '@cowprotocol/ui'
1+
import { useHydrateAtoms } from 'jotai/utils'
2+
import { ReactNode, useEffect, useLayoutEffect, useMemo } from 'react'
43

54
import { t } from '@lingui/core/macro'
65

7-
import { TitleBarActions, useSelectTokenContext, useTokenSearchInput } from './helpers'
8-
import { MobileChainSelector } from './MobileChainSelector'
9-
import * as styledEl from './styled'
6+
import { getMobileChainSelectorConfig, useSelectTokenContext, useTokenSearchInput } from './helpers'
7+
import { SelectTokenModalShell } from './SelectTokenModalShell'
108
import { TokenColumnContent } from './TokenColumnContent'
119

10+
import { useResetTokenListViewState } from '../../hooks/useResetTokenListViewState'
11+
import { useUpdateTokenListViewState } from '../../hooks/useUpdateTokenListViewState'
12+
import { tokenListViewAtom, TokenListViewState } from '../../state/tokenListViewAtom'
1213
import { ChainPanel } from '../ChainPanel'
1314
import { TokensContent } from '../TokensContent'
1415

@@ -64,6 +65,53 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
6465
chainsPanelTitle,
6566
})
6667

68+
// Compute the view state to hydrate the atom
69+
const initialViewState: TokenListViewState = useMemo(
70+
() => ({
71+
allTokens,
72+
favoriteTokens,
73+
recentTokens,
74+
searchInput: trimmedInputValue,
75+
areTokensLoading,
76+
areTokensFromBridge,
77+
hideFavoriteTokensTooltip: hideFavoriteTokensTooltip ?? false,
78+
displayLpTokenLists: displayLpTokenLists ?? false,
79+
selectedTargetChainId,
80+
selectTokenContext,
81+
onClearRecentTokens,
82+
}),
83+
[
84+
allTokens,
85+
favoriteTokens,
86+
recentTokens,
87+
trimmedInputValue,
88+
areTokensLoading,
89+
areTokensFromBridge,
90+
hideFavoriteTokensTooltip,
91+
displayLpTokenLists,
92+
selectedTargetChainId,
93+
selectTokenContext,
94+
onClearRecentTokens,
95+
],
96+
)
97+
98+
// Hydrate atom SYNCHRONOUSLY on first render (no useEffect!)
99+
useHydrateAtoms([[tokenListViewAtom, initialViewState]])
100+
101+
// Keep atom in sync when props change (after initial render)
102+
// Using useLayoutEffect to ensure atom is updated before paint, avoiding flicker
103+
// Note: Always pass the full state object; partial updates may leave stale fields
104+
const updateTokenListView = useUpdateTokenListViewState()
105+
useLayoutEffect(() => {
106+
updateTokenListView(initialViewState)
107+
}, [initialViewState, updateTokenListView])
108+
109+
// Reset atom on unmount to avoid stale state on reopen (full replace, not partial merge)
110+
const resetTokenListView = useResetTokenListViewState()
111+
useEffect(() => {
112+
return () => resetTokenListView()
113+
}, [resetTokenListView])
114+
67115
const mobileChainSelector = getMobileChainSelectorConfig({
68116
showChainPanel,
69117
mobileChainsState,
@@ -99,85 +147,12 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
99147
chainsToSelect={chainsForTokenColumn}
100148
onSelectChain={onSelectChain}
101149
>
102-
<TokensContent
103-
displayLpTokenLists={displayLpTokenLists}
104-
selectTokenContext={selectTokenContext}
105-
favoriteTokens={favoriteTokens}
106-
recentTokens={recentTokens}
107-
areTokensLoading={areTokensLoading}
108-
allTokens={allTokens}
109-
searchInput={trimmedInputValue}
110-
areTokensFromBridge={areTokensFromBridge}
111-
hideFavoriteTokensTooltip={hideFavoriteTokensTooltip}
112-
selectedTargetChainId={selectedTargetChainId}
113-
onClearRecentTokens={onClearRecentTokens}
114-
/>
150+
<TokensContent />
115151
</TokenColumnContent>
116152
</SelectTokenModalShell>
117153
)
118154
}
119155

120-
interface SelectTokenModalShellProps {
121-
children: ReactNode
122-
hasChainPanel: boolean
123-
isFullScreenMobile?: boolean
124-
title: string
125-
showManageButton: boolean
126-
onDismiss(): void
127-
onOpenManageWidget: () => void
128-
searchValue: string
129-
onSearchChange(value: string): void
130-
onSearchEnter?: () => void
131-
mobileChainSelector?: ComponentProps<typeof MobileChainSelector>
132-
sideContent?: ReactNode
133-
}
134-
135-
function SelectTokenModalShell({
136-
children,
137-
hasChainPanel,
138-
isFullScreenMobile,
139-
title,
140-
showManageButton,
141-
onDismiss,
142-
onOpenManageWidget,
143-
searchValue,
144-
onSearchChange,
145-
onSearchEnter,
146-
mobileChainSelector,
147-
sideContent,
148-
}: SelectTokenModalShellProps): ReactNode {
149-
return (
150-
<styledEl.Wrapper $hasChainPanel={hasChainPanel} $isFullScreen={isFullScreenMobile}>
151-
<TitleBarActions
152-
showManageButton={showManageButton}
153-
onDismiss={onDismiss}
154-
onOpenManageWidget={onOpenManageWidget}
155-
title={title}
156-
/>
157-
<styledEl.SearchRow>
158-
<styledEl.SearchInputWrapper>
159-
<SearchInput
160-
id="token-search-input"
161-
value={searchValue}
162-
onKeyDown={(event) => {
163-
if (event.key === 'Enter') {
164-
onSearchEnter?.()
165-
}
166-
}}
167-
onChange={(event) => onSearchChange(event.target.value)}
168-
placeholder={t`Search name or paste address...`}
169-
/>
170-
</styledEl.SearchInputWrapper>
171-
</styledEl.SearchRow>
172-
{mobileChainSelector ? <MobileChainSelector {...mobileChainSelector} /> : null}
173-
<styledEl.Body>
174-
<styledEl.TokenColumn>{children}</styledEl.TokenColumn>
175-
{sideContent}
176-
</styledEl.Body>
177-
</styledEl.Wrapper>
178-
)
179-
}
180-
181156
function useSelectTokenModalLayout(props: SelectTokenModalProps): {
182157
inputValue: string
183158
setInputValue: (value: string) => void
@@ -223,35 +198,3 @@ function useSelectTokenModalLayout(props: SelectTokenModalProps): {
223198
resolvedModalTitle,
224199
}
225200
}
226-
227-
function getMobileChainSelectorConfig({
228-
showChainPanel,
229-
mobileChainsState,
230-
mobileChainsLabel,
231-
onSelectChain,
232-
onOpenMobileChainPanel,
233-
}: {
234-
showChainPanel: boolean
235-
mobileChainsState: SelectTokenModalProps['mobileChainsState']
236-
mobileChainsLabel: SelectTokenModalProps['mobileChainsLabel']
237-
onSelectChain?: SelectTokenModalProps['onSelectChain']
238-
onOpenMobileChainPanel?: SelectTokenModalProps['onOpenMobileChainPanel']
239-
}): ComponentProps<typeof MobileChainSelector> | undefined {
240-
const canRender =
241-
!showChainPanel &&
242-
mobileChainsState &&
243-
onSelectChain &&
244-
onOpenMobileChainPanel &&
245-
(mobileChainsState.chains?.length ?? 0) > 0
246-
247-
if (!canRender) {
248-
return undefined
249-
}
250-
251-
return {
252-
chainsState: mobileChainsState,
253-
label: mobileChainsLabel,
254-
onSelectChain,
255-
onOpenPanel: onOpenMobileChainPanel,
256-
}
257-
}

apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { PermitCompatibleTokens } from 'modules/permit'
1010

1111
import { ChainsToSelectState, TokenSelectionHandler } from '../../types'
1212

13-
// TODO: Refactor to reduce prop count
1413
export interface TokenListContentProps<T = TokenListCategory[] | null> {
1514
allTokens: TokenWithLogo[]
1615
favoriteTokens: TokenWithLogo[]

0 commit comments

Comments
 (0)