Skip to content

Commit 86a2006

Browse files
committed
feat: add controllable state reducer utility
1 parent f6dced2 commit 86a2006

3 files changed

Lines changed: 175 additions & 4 deletions

File tree

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
# @fictjs/use-controllable-state
22

3-
A Fict-native controllable state helper inspired by `@radix-ui/react-use-controllable-state`. It returns an accessor for the current value plus a setter that supports controlled and uncontrolled flows.
3+
Fict-native controllable state helpers inspired by
4+
`@radix-ui/react-use-controllable-state`.
5+
6+
Exports:
7+
8+
- `useControllableState`
9+
- `useControllableStateReducer`

packages/use-controllable-state/src/index.ts

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { useLayoutEffect } from '@fictjs/use-layout-effect'
1515

1616
type ChangeHandler<T> = (state: T) => void
1717
type SetStateFn<T> = (value: T | ((prev: T) => T)) => void
18+
type Dispatch<A> = (action: A) => void
1819

1920
interface UseControllableStateParams<T> {
2021
prop?: MaybeAccessor<T | undefined>
@@ -23,6 +24,10 @@ interface UseControllableStateParams<T> {
2324
caller?: string
2425
}
2526

27+
type AnyAction = {
28+
type: string
29+
}
30+
2631
const noop = () => {}
2732

2833
function useControllableState<T>({
@@ -81,5 +86,84 @@ function useControllableState<T>({
8186
return [value, setValue]
8287
}
8388

84-
export { useControllableState }
85-
export type { ChangeHandler, SetStateFn, UseControllableStateParams }
89+
function useControllableStateReducer<T, S extends object, A extends AnyAction>(
90+
reducer: (prevState: S & { state: T }, action: A) => S & { state: T },
91+
userArgs: UseControllableStateParams<T>,
92+
initialState: Omit<S, 'state'>,
93+
): [() => S & { state: T }, Dispatch<A>]
94+
function useControllableStateReducer<T, S extends object, I extends object, A extends AnyAction>(
95+
reducer: (prevState: S & { state: T }, action: A) => S & { state: T },
96+
userArgs: UseControllableStateParams<T>,
97+
initialArg: I,
98+
init: (value: I & { state: T }) => Omit<S, 'state'>,
99+
): [() => S & { state: T }, Dispatch<A>]
100+
function useControllableStateReducer<T, S extends object, I extends object, A extends AnyAction>(
101+
reducer: (prevState: S & { state: T }, action: A) => S & { state: T },
102+
{ prop, defaultProp, onChange = noop as ChangeHandler<T>, caller }: UseControllableStateParams<T>,
103+
initialArg: Omit<S, 'state'> | I,
104+
init?: (value: I & { state: T }) => Omit<S, 'state'>,
105+
): [() => S & { state: T }, Dispatch<A>] {
106+
const defaultState = () => readValue(defaultProp)
107+
const controlledState = () => (prop === undefined ? undefined : readValue(prop))
108+
const emitChange = useEffectEvent(onChange)
109+
const createInitialState = (): S & { state: T } => {
110+
const state = defaultState()
111+
const baseState = init
112+
? init({ ...(initialArg as I), state })
113+
: (initialArg as Omit<S, 'state'>)
114+
115+
return { ...baseState, state } as S & { state: T }
116+
}
117+
const internalState = createSignal<S & { state: T }>(createInitialState())
118+
119+
let lastMode = controlledState() !== undefined
120+
useLayoutEffect(() => {
121+
const nextMode = controlledState() !== undefined
122+
123+
if (process.env.NODE_ENV !== 'production' && lastMode !== nextMode) {
124+
const from = lastMode ? 'controlled' : 'uncontrolled'
125+
const to = nextMode ? 'controlled' : 'uncontrolled'
126+
const label = caller ?? 'useControllableStateReducer'
127+
console.warn(
128+
label +
129+
' is changing from ' +
130+
from +
131+
' to ' +
132+
to +
133+
'. Components should stay either controlled or uncontrolled for their lifetime.',
134+
)
135+
}
136+
137+
lastMode = nextMode
138+
})
139+
140+
const state = () => {
141+
const currentInternalState = internalState()
142+
const currentProp = controlledState()
143+
144+
if (currentProp === undefined) {
145+
return currentInternalState
146+
}
147+
148+
return {
149+
...currentInternalState,
150+
state: currentProp,
151+
}
152+
}
153+
154+
const dispatch: Dispatch<A> = (action) => {
155+
const prevState = state()
156+
const nextState = reducer(prevState, action)
157+
158+
internalState(nextState)
159+
160+
if (!Object.is(prevState.state, nextState.state)) {
161+
emitChange(nextState.state)
162+
}
163+
}
164+
165+
return [state, dispatch]
166+
}
167+
168+
export { useControllableState, useControllableStateReducer }
169+
export type { AnyAction, ChangeHandler, Dispatch, SetStateFn, UseControllableStateParams }

packages/use-controllable-state/test/index.test.tsx

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
55
import { render } from '@fictjs/runtime'
66
import { createSignal } from '@fictjs/runtime/advanced'
77

8-
import { useControllableState } from '../src/index.js'
8+
import { useControllableState, useControllableStateReducer } from '../src/index.js'
99

1010
describe('@fictjs/use-controllable-state', () => {
1111
it('updates uncontrolled state and emits changes', () => {
@@ -40,4 +40,85 @@ describe('@fictjs/use-controllable-state', () => {
4040
expect(value?.()).toBe('first')
4141
expect(onChange).toHaveBeenCalledWith('second')
4242
})
43+
44+
it('updates reducer state for uncontrolled usage', () => {
45+
type ReducerState = {
46+
count: number
47+
state: string
48+
}
49+
type ReducerAction = {
50+
type: 'increment'
51+
} | {
52+
type: 'select'
53+
value: string
54+
}
55+
56+
let state: (() => ReducerState) | undefined
57+
let dispatch: ((action: ReducerAction) => void) | undefined
58+
const onChange = vi.fn()
59+
60+
render(() => {
61+
;[state, dispatch] = useControllableStateReducer<string, Omit<ReducerState, 'state'>, ReducerAction>(
62+
(prevState, action) => {
63+
if (action.type === 'increment') {
64+
return { ...prevState, count: prevState.count + 1 }
65+
}
66+
67+
return { ...prevState, state: action.value }
68+
},
69+
{ defaultProp: 'alpha', onChange },
70+
{ count: 0 },
71+
)
72+
return <div />
73+
}, document.createElement('div'))
74+
75+
expect(state?.()).toEqual({ count: 0, state: 'alpha' })
76+
77+
dispatch?.({ type: 'increment' })
78+
expect(state?.()).toEqual({ count: 1, state: 'alpha' })
79+
80+
dispatch?.({ type: 'select', value: 'beta' })
81+
expect(state?.()).toEqual({ count: 1, state: 'beta' })
82+
expect(onChange).toHaveBeenCalledWith('beta')
83+
})
84+
85+
it('emits reducer state changes while preserving controlled value reads', () => {
86+
type ReducerState = {
87+
count: number
88+
state: string
89+
}
90+
type ReducerAction = {
91+
type: 'increment'
92+
} | {
93+
type: 'select'
94+
value: string
95+
}
96+
97+
const controlled = createSignal('first')
98+
let state: (() => ReducerState) | undefined
99+
let dispatch: ((action: ReducerAction) => void) | undefined
100+
const onChange = vi.fn()
101+
102+
render(() => {
103+
;[state, dispatch] = useControllableStateReducer<string, Omit<ReducerState, 'state'>, ReducerAction>(
104+
(prevState, action) => {
105+
if (action.type === 'increment') {
106+
return { ...prevState, count: prevState.count + 1 }
107+
}
108+
109+
return { ...prevState, state: action.value }
110+
},
111+
{ prop: () => controlled(), defaultProp: 'fallback', onChange },
112+
{ count: 0 },
113+
)
114+
return <div />
115+
}, document.createElement('div'))
116+
117+
dispatch?.({ type: 'increment' })
118+
expect(state?.()).toEqual({ count: 1, state: 'first' })
119+
120+
dispatch?.({ type: 'select', value: 'second' })
121+
expect(state?.()).toEqual({ count: 1, state: 'first' })
122+
expect(onChange).toHaveBeenCalledWith('second')
123+
})
43124
})

0 commit comments

Comments
 (0)