diff --git a/.changeset/state-hook-returns.md b/.changeset/state-hook-returns.md new file mode 100644 index 00000000..3062b7c2 --- /dev/null +++ b/.changeset/state-hook-returns.md @@ -0,0 +1,19 @@ +--- +'react-simplikit': major +--- + +`useBooleanState`, `useStorageState` and `useCounter` now return `[value, actions]`, matching `useList`, `useMap` and `useSet`. The actions object keeps the same reference across renders. + +```ts +// before +const [isOpen, setTrue, setFalse, toggle] = useBooleanState(false); +const [token, setToken, refreshToken] = useStorageState('token'); +const { count, increment, decrement, reset, setCount } = useCounter(0); + +// after +const [isOpen, { setTrue, setFalse, toggle }] = useBooleanState(false); +const [token, { setValue, refresh }] = useStorageState('token'); +const [count, { increment, decrement, reset, setCount }] = useCounter(0); +``` + +`useToggle` keeps its `[value, toggle]` signature and now declares its return type, so React 18 consumers no longer see the React 19 `ActionDispatch` type. diff --git a/AGENTS.md b/AGENTS.md index 4e92374c..119d49b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,9 +116,9 @@ Never initialize state with browser API calls (causes hydration mismatch). ### Hook Return Value Convention - **Single value**: `useDebounce(value, delay): T` -- **Tuple** (2 items): `useToggle(init): [boolean, () => void]` -- **Object** (3+ items): `usePagination(): { page, nextPage, prevPage }` -- **Object** also when the shape is expected to grow: `useKeyboardHeight(): { keyboardHeight }` +- **Tuple** (state + one action): `useToggle(init): [boolean, () => void]` +- **Tuple with an actions object** (state + two or more actions): `useBooleanState(init): [boolean, { setTrue, setFalse, toggle }]`, `useList`, `useCounter` +- **Object** for results that are not state + actions, and whenever the shape is expected to grow: browser measurements such as `useKeyboardHeight(): { keyboardHeight }` ## Testing diff --git a/CLAUDE.md b/CLAUDE.md index 2809c868..7332c8fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,9 +89,9 @@ Never initialize state with browser API calls (causes hydration mismatch). ### Hook Return Value Convention - **Single value**: `useDebounce(value, delay): T` -- **Tuple** (state + action, 2 items): `useToggle(init): [boolean, () => void]` -- **Object** (3+ items): `usePagination(): { page, nextPage, prevPage }` -- **Object** also when the shape is expected to grow: browser measurements such as `useKeyboardHeight(): { keyboardHeight }` +- **Tuple** (state + one action): `useToggle(init): [boolean, () => void]` +- **Tuple with an actions object** (state + two or more actions): `useBooleanState(init): [boolean, { setTrue, setFalse, toggle }]`, `useList`, `useCounter` +- **Object** for results that are not state + actions, and whenever the shape is expected to grow: browser measurements such as `useKeyboardHeight(): { keyboardHeight }` ### Performance Patterns diff --git a/packages/plugin/skills/react-simplikit/SKILL.md b/packages/plugin/skills/react-simplikit/SKILL.md index 0004a1fd..ea4c0113 100644 --- a/packages/plugin/skills/react-simplikit/SKILL.md +++ b/packages/plugin/skills/react-simplikit/SKILL.md @@ -106,7 +106,7 @@ Backticks in this table mark catalog entries only. | [`useSafeAreaInset`](references/useSafeAreaInset.md) | `useSafeAreaInset` is a React hook that tracks safe area inset changes. | | [`useScrollDirection`](references/useScrollDirection.md) | `useScrollDirection` is a React hook that detects scroll direction. | | [`useSet`](references/useSet.md) | A React hook that manages a Set as state. | -| [`useStorageState`](references/useStorageState.md) | `useStorageState` is a React that functions like `useState` but persists the state value in browser storage. | +| [`useStorageState`](references/useStorageState.md) | `useStorageState` is a React hook that functions like `useState` but persists the state value in browser storage. | | [`useThrottle`](references/useThrottle.md) | `useThrottle` is a React hook that creates a throttled version of a callback function. | | [`useThrottledCallback`](references/useThrottledCallback.md) | `useThrottledCallback` is a React hook that returns a throttled version of the provided callback function. | | [`useThrottledValue`](references/useThrottledValue.md) | `useThrottledValue` is a React hook that returns a throttled copy of the given value. | diff --git a/packages/plugin/skills/react-simplikit/references/useBooleanState.md b/packages/plugin/skills/react-simplikit/references/useBooleanState.md index 3ef27c3e..50443689 100644 --- a/packages/plugin/skills/react-simplikit/references/useBooleanState.md +++ b/packages/plugin/skills/react-simplikit/references/useBooleanState.md @@ -8,12 +8,7 @@ It provides functions to set the state to `true`, set it to `false`, and toggle ```ts function useBooleanState( initialValue: boolean | (() => boolean) = false -): readonly [ - state: boolean, - setTrue: () => void, - setFalse: () => void, - toggle: () => void, -]; +): readonly [state: boolean, actions: BooleanStateActions]; ``` ### Parameters @@ -28,8 +23,8 @@ function useBooleanState( @@ -97,7 +97,7 @@ function useCounter( import { useCounter } from 'react-simplikit'; function ShoppingCart() { - const { count, increment, decrement, reset } = useCounter(1, { + const [count, { increment, decrement, reset }] = useCounter(1, { min: 1, max: 10, }); diff --git a/packages/plugin/skills/react-simplikit/references/useStorageState.md b/packages/plugin/skills/react-simplikit/references/useStorageState.md index 08ee4a2c..851d5d84 100644 --- a/packages/plugin/skills/react-simplikit/references/useStorageState.md +++ b/packages/plugin/skills/react-simplikit/references/useStorageState.md @@ -1,6 +1,6 @@ # useStorageState -`useStorageState` is a React that functions like `useState` but persists the state value in browser storage. +`useStorageState` is a React hook that functions like `useState` but persists the state value in browser storage. The value is retained across page reloads and can be shared between tabs when using `localStorage`. ## Interface @@ -11,8 +11,7 @@ function useStorageState( options?: Object ): readonly [ state: Serializable | undefined, - setState: (value: SetStateAction | undefined>) => void, - refreshState: () => void, + actions: StorageStateActions | undefined>, ]; ``` @@ -63,8 +62,8 @@ function useStorageState( @@ -97,7 +97,7 @@ function useCounter( import { useCounter } from 'react-simplikit'; function ShoppingCart() { - const { count, increment, decrement, reset } = useCounter(1, { + const [count, { increment, decrement, reset }] = useCounter(1, { min: 1, max: 10, }); diff --git a/packages/react-simplikit/src/hooks/useCounter/ja/useCounter.md b/packages/react-simplikit/src/hooks/useCounter/ja/useCounter.md index 55843629..3ed3033e 100644 --- a/packages/react-simplikit/src/hooks/useCounter/ja/useCounter.md +++ b/packages/react-simplikit/src/hooks/useCounter/ja/useCounter.md @@ -55,7 +55,7 @@ function useCounter( @@ -97,7 +97,7 @@ function useCounter( import { useCounter } from 'react-simplikit'; function ShoppingCart() { - const { count, increment, decrement, reset } = useCounter(1, { + const [count, { increment, decrement, reset }] = useCounter(1, { min: 1, max: 10, }); diff --git a/packages/react-simplikit/src/hooks/useCounter/ko/useCounter.md b/packages/react-simplikit/src/hooks/useCounter/ko/useCounter.md index d176a0ec..f3883246 100644 --- a/packages/react-simplikit/src/hooks/useCounter/ko/useCounter.md +++ b/packages/react-simplikit/src/hooks/useCounter/ko/useCounter.md @@ -54,7 +54,7 @@ function useCounter( @@ -96,7 +96,7 @@ function useCounter( import { useCounter } from 'react-simplikit'; function ShoppingCart() { - const { count, increment, decrement, reset } = useCounter(1, { + const [count, { increment, decrement, reset }] = useCounter(1, { min: 1, max: 10, }); diff --git a/packages/react-simplikit/src/hooks/useCounter/useCounter.md b/packages/react-simplikit/src/hooks/useCounter/useCounter.md index fcfa9049..51f8edd9 100644 --- a/packages/react-simplikit/src/hooks/useCounter/useCounter.md +++ b/packages/react-simplikit/src/hooks/useCounter/useCounter.md @@ -55,7 +55,7 @@ function useCounter( @@ -97,7 +97,7 @@ function useCounter( import { useCounter } from 'react-simplikit'; function ShoppingCart() { - const { count, increment, decrement, reset } = useCounter(1, { + const [count, { increment, decrement, reset }] = useCounter(1, { min: 1, max: 10, }); diff --git a/packages/react-simplikit/src/hooks/useCounter/useCounter.spec.ts b/packages/react-simplikit/src/hooks/useCounter/useCounter.spec.ts index 92784749..950033b0 100644 --- a/packages/react-simplikit/src/hooks/useCounter/useCounter.spec.ts +++ b/packages/react-simplikit/src/hooks/useCounter/useCounter.spec.ts @@ -7,50 +7,50 @@ describe('useCounter', () => { it('should initialize with default value', () => { const { result } = renderHook(() => useCounter()); - expect(result.current.count).toBe(0); + expect(result.current[0]).toBe(0); }); it('should initialize with provided initial value', () => { const { result } = renderHook(() => useCounter(10)); - expect(result.current.count).toBe(10); + expect(result.current[0]).toBe(10); }); it('should increment the counter', async () => { const { result } = renderHook(() => useCounter(5)); await act(async () => { - result.current.increment(); + result.current[1].increment(); }); - expect(result.current.count).toBe(6); + expect(result.current[0]).toBe(6); }); it('should decrement the counter', async () => { const { result } = renderHook(() => useCounter(5)); await act(async () => { - result.current.decrement(); + result.current[1].decrement(); }); - expect(result.current.count).toBe(4); + expect(result.current[0]).toBe(4); }); it('should reset the counter to initial value', async () => { const { result } = renderHook(() => useCounter(5)); await act(async () => { - result.current.increment(); - result.current.increment(); + result.current[1].increment(); + result.current[1].increment(); }); - expect(result.current.count).toBe(7); + expect(result.current[0]).toBe(7); await act(async () => { - result.current.reset(); + result.current[1].reset(); }); - expect(result.current.count).toBe(5); + expect(result.current[0]).toBe(5); }); it('should not go below minimum value', async () => { @@ -61,12 +61,12 @@ describe('useCounter', () => { ); await act(async () => { - result.current.decrement(); - result.current.decrement(); - result.current.decrement(); + result.current[1].decrement(); + result.current[1].decrement(); + result.current[1].decrement(); }); - expect(result.current.count).toBe(3); + expect(result.current[0]).toBe(3); }); it('should not go above maximum value', async () => { @@ -77,12 +77,12 @@ describe('useCounter', () => { ); await act(async () => { - result.current.increment(); - result.current.increment(); - result.current.increment(); + result.current[1].increment(); + result.current[1].increment(); + result.current[1].increment(); }); - expect(result.current.count).toBe(7); + expect(result.current[0]).toBe(7); }); it('should use the provided step value for increment and decrement', async () => { @@ -93,16 +93,16 @@ describe('useCounter', () => { ); await act(async () => { - result.current.increment(); + result.current[1].increment(); }); - expect(result.current.count).toBe(7); + expect(result.current[0]).toBe(7); await act(async () => { - result.current.decrement(); + result.current[1].decrement(); }); - expect(result.current.count).toBe(5); + expect(result.current[0]).toBe(5); }); it('should adjust initial value to match constraints', () => { @@ -112,7 +112,7 @@ describe('useCounter', () => { }) ); - expect(result.current.count).toBe(3); + expect(result.current[0]).toBe(3); const { result: result2 } = renderHook(() => useCounter(10, { @@ -120,7 +120,7 @@ describe('useCounter', () => { }) ); - expect(result2.current.count).toBe(8); + expect(result2.current[0]).toBe(8); }); it('should allow setting arbitrary value within constraints', async () => { @@ -132,31 +132,43 @@ describe('useCounter', () => { ); await act(async () => { - result.current.setCount(6); + result.current[1].setCount(6); }); - expect(result.current.count).toBe(6); + expect(result.current[0]).toBe(6); await act(async () => { - result.current.setCount(1); + result.current[1].setCount(1); }); - expect(result.current.count).toBe(3); + expect(result.current[0]).toBe(3); await act(async () => { - result.current.setCount(10); + result.current[1].setCount(10); }); - expect(result.current.count).toBe(8); + expect(result.current[0]).toBe(8); }); it('should work with updater function for setCount', async () => { const { result } = renderHook(() => useCounter(5)); await act(async () => { - result.current.setCount(prev => prev + 3); + result.current[1].setCount(prev => prev + 3); }); - expect(result.current.count).toBe(8); + expect(result.current[0]).toBe(8); + }); + + it('should keep the actions object stable across renders', () => { + const { result } = renderHook(() => useCounter(0)); + const [, actions] = result.current; + + act(() => { + actions.increment(); + }); + + expect(result.current[0]).toBe(1); + expect(result.current[1]).toBe(actions); }); }); diff --git a/packages/react-simplikit/src/hooks/useCounter/useCounter.ts b/packages/react-simplikit/src/hooks/useCounter/useCounter.ts index 12af9125..0568ec1e 100644 --- a/packages/react-simplikit/src/hooks/useCounter/useCounter.ts +++ b/packages/react-simplikit/src/hooks/useCounter/useCounter.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; type UseCounterOptions = { min?: number; @@ -6,14 +6,15 @@ type UseCounterOptions = { step?: number; }; -type UseCounterReturn = { - count: number; +type CounterActions = { increment: () => void; decrement: () => void; reset: () => void; setCount: (value: number | ((prev: number) => number)) => void; }; +type UseCounterReturn = readonly [number, CounterActions]; + const validateValue = (value: number, { min, max }: Omit): number => { if (min !== undefined && value < min) { return min; @@ -37,18 +38,18 @@ const validateValue = (value: number, { min, max }: Omit void` - A function to increment the count; - * - decrement `() => void` - A function to decrement the count; - * - reset `() => void` - A function to reset the count to the initial value; - * - setCount `(value: number | ((prev: number) => number)) => void` - A function to set the count to a specific value or a function that returns a new value; + * - actions.increment `() => void` - A function to increment the count; + * - actions.decrement `() => void` - A function to decrement the count; + * - actions.reset `() => void` - A function to reset the count to the initial value; + * - actions.setCount `(value: number | ((prev: number) => number)) => void` - A function to set the count, either to a value or with an updater function that receives the previous count and returns the next one; * * @example * import { useCounter } from 'react-simplikit'; * * function ShoppingCart() { - * const { count, increment, decrement, reset } = useCounter(1, { + * const [count, { increment, decrement, reset }] = useCounter(1, { * min: 1, * max: 10, * }); @@ -89,11 +90,10 @@ export function useCounter(initialValue = 0, { min, max, step = 1 }: UseCounterO setCount(initialValue); }, [setCount, initialValue]); - return { - count, - increment, - decrement, - reset, - setCount, - }; + const actions = useMemo( + () => ({ increment, decrement, reset, setCount }), + [increment, decrement, reset, setCount] + ); + + return [count, actions] as const; } diff --git a/packages/react-simplikit/src/hooks/useCounter/zh-Hans/useCounter.md b/packages/react-simplikit/src/hooks/useCounter/zh-Hans/useCounter.md index 6c1fe3f3..dda11d5c 100644 --- a/packages/react-simplikit/src/hooks/useCounter/zh-Hans/useCounter.md +++ b/packages/react-simplikit/src/hooks/useCounter/zh-Hans/useCounter.md @@ -54,7 +54,7 @@ function useCounter( @@ -96,7 +96,7 @@ function useCounter( import { useCounter } from 'react-simplikit'; function ShoppingCart() { - const { count, increment, decrement, reset } = useCounter(1, { + const [count, { increment, decrement, reset }] = useCounter(1, { min: 1, max: 10, }); diff --git a/packages/react-simplikit/src/hooks/useStorageState/es/useStorageState.md b/packages/react-simplikit/src/hooks/useStorageState/es/useStorageState.md index a10a3cc7..f6c11570 100644 --- a/packages/react-simplikit/src/hooks/useStorageState/es/useStorageState.md +++ b/packages/react-simplikit/src/hooks/useStorageState/es/useStorageState.md @@ -11,8 +11,7 @@ function useStorageState( options?: Object ): readonly [ state: Serializable | undefined, - setState: (value: SetStateAction | undefined>) => void, - refreshState: () => void, + actions: StorageStateActions | undefined>, ]; ``` @@ -63,8 +62,8 @@ function useStorageState(