Skip to content

Commit 255320e

Browse files
authored
refactor(ui): simplify ConfigProvider, improve useControllableState types and defaultValue fallback (#14409)
Our `ConfigProvider` has a `useEffect` that updates its initial state if the config from props changes (e.g. due to HMR running). Turns out @jacobsfletch added this awesome hook called `useControllableState` that already does exactly this: manage an internal state and react to prop changes while ensuring it does not run unnecessarily on mount. --- **Improvements to `useControllableState`:** - **Uses `useControllableState` in `ConfigProvider`** - reduces duplicative state management code - **Simplifies implementation** - removes redundant `useCallback` wrapper around `setLocalValue` - **Fixes fallback behavior** - `defaultValue` now consistently returns when value is `null` or `undefined`, not just on initial render. Previously would fail to fallback if value became null/undefined after mounting (e.g. if useEffect ran). - **Improves type safety** - `defaultValue` is a separate generic that properly types the return value **Type example:** ```typescript const propValue: string | undefined = 'test' as string | undefined // Without defaultValue - return type for value is string | undefined const [value, setValue] = useControllableState(propValue) // With defaultValue - return type for value is string. Before this PR, it would be string | undefined const [value, setValue] = useControllableState(propValue, 'fallback') ```
1 parent d5fab43 commit 255320e

File tree

2 files changed

+22
-23
lines changed

2 files changed

+22
-23
lines changed
Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
11
'use client'
2-
import { useCallback, useEffect, useRef, useState } from 'react'
2+
import { useEffect, useRef, useState } from 'react'
33

44
/**
55
* A hook for managing state that can be controlled by props but also overridden locally.
66
* Props always take precedence if they change, but local state can override them temporarily.
77
*
8+
* @param propValue - The controlled value from props
9+
* @param fallbackValue - Value to use when propValue is null or undefined
10+
*
811
* @internal - may change or be removed without a major version bump
912
*/
10-
export function useControllableState<T>(
13+
export function useControllableState<T, D>(
14+
propValue: T,
15+
fallbackValue: D,
16+
): [T extends NonNullable<T> ? T : D | NonNullable<T>, (value: ((prev: T) => T) | T) => void]
17+
export function useControllableState<T>(propValue: T): [T, (value: ((prev: T) => T) | T) => void]
18+
export function useControllableState<T, D>(
1119
propValue: T,
12-
defaultValue?: T,
13-
): [T, (value: ((prev: T) => T) | T) => void] {
14-
const [localValue, setLocalValue] = useState<T>(propValue ?? defaultValue)
20+
fallbackValue?: D,
21+
): [T extends NonNullable<T> ? T : D | NonNullable<T>, (value: ((prev: T) => T) | T) => void] {
22+
const [localValue, setLocalValue] = useState<T>(propValue)
1523
const initialRenderRef = useRef(true)
1624

1725
useEffect(() => {
@@ -23,9 +31,8 @@ export function useControllableState<T>(
2331
setLocalValue(propValue)
2432
}, [propValue])
2533

26-
const setValue = useCallback((value: ((prev: T) => T) | T) => {
27-
setLocalValue(value)
28-
}, [])
29-
30-
return [localValue, setValue]
34+
return [localValue ?? fallbackValue, setLocalValue] as [
35+
T extends NonNullable<T> ? T : D | NonNullable<T>,
36+
(value: ((prev: T) => T) | T) => void,
37+
]
3138
}

packages/ui/src/providers/Config/index.tsx

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import type {
88
GlobalSlug,
99
} from 'payload'
1010

11-
import React, { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from 'react'
11+
import React, { createContext, use, useCallback, useEffect, useMemo } from 'react'
12+
13+
import { useControllableState } from '../../hooks/useControllableState.js'
1214

1315
type GetEntityConfigFn = {
1416
// Overload #1: collectionSlug only
@@ -43,20 +45,10 @@ export const ConfigProvider: React.FC<{
4345
readonly children: React.ReactNode
4446
readonly config: ClientConfig
4547
}> = ({ children, config: configFromProps }) => {
46-
const [config, setConfig] = useState<ClientConfig>(configFromProps)
47-
48-
const isFirstRenderRef = useRef(true)
49-
5048
// Need to update local config state if config from props changes, for HMR.
5149
// That way, config changes will be updated in the UI immediately without needing a refresh.
52-
useEffect(() => {
53-
if (isFirstRenderRef.current) {
54-
isFirstRenderRef.current = false
55-
return
56-
}
57-
58-
setConfig(configFromProps)
59-
}, [configFromProps, setConfig])
50+
// useControllableState handles this for us.
51+
const [config, setConfig] = useControllableState<ClientConfig>(configFromProps)
6052

6153
// Build lookup maps for collections and globals so we can do O(1) lookups by slug
6254
const { collectionsBySlug, globalsBySlug } = useMemo(() => {

0 commit comments

Comments
 (0)