Skip to content

Commit a8ee06f

Browse files
Merge pull request #23 from commitd/useUnuseControllableState
Adding useControllable hook
2 parents 9651d5b + 3d031ce commit a8ee06f

14 files changed

+1272
-876
lines changed

Diff for: package.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "0.3.0",
2+
"version": "0.4.0",
33
"name": "@committed/hooks",
44
"description": "Committed hooks library",
55
"author": "Committed",
@@ -74,8 +74,8 @@
7474
"@material-ui/icons": "^4.9.1",
7575
"@material-ui/lab": "^4.0.0-alpha.56",
7676
"@size-limit/preset-small-lib": "^4.6.2",
77-
"@storybook/addon-essentials": "^6.0.28",
78-
"@storybook/react": "^6.0.28",
77+
"@storybook/addon-essentials": "^6.1.14",
78+
"@storybook/react": "^6.1.14",
7979
"@storybook/storybook-deployer": "^2.8.7",
8080
"@testing-library/jest-dom": "^5.11.5",
8181
"@testing-library/react": "^11.1.0",

Diff for: src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export * from './usePoll'
1111
export * from './useTimeout'
1212
export * from './useTitle'
1313
export * from './useTrackedState'
14+
export * from './useControllableState'

Diff for: src/useControllableState/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useControllableState'
+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import React from 'react'
2+
import { Story, Meta } from '@storybook/react'
3+
import { useControllableState } from '.'
4+
import { CheckToken } from '@committed/components'
5+
import { action } from '@storybook/addon-actions'
6+
7+
export interface UseControllableStateDocsProps<T> {
8+
/** The controlled value (of type T) or undefined for an uncontrolled value */
9+
value: T | undefined
10+
/** The dispatch handler for state changes or undefined for when an uncontrolled value, ignored if uncontrolled*/
11+
setValue: React.Dispatch<React.SetStateAction<T>> | undefined
12+
/** The initial state value, or state initializer for when uncontrolled, ignored if controlled */
13+
initialState?: T | (() => T | undefined) | undefined
14+
}
15+
16+
/**
17+
* useControllableState hook for when the state may be controlled or uncontrolled.
18+
*
19+
* Returns as the standard useState hook, but has additional props of a controlled value and a controlled change handler.
20+
* Set these using the components incoming props for the state, if defined they will be used, if not you get the standard useState behaviour.
21+
*/
22+
export const UseControllableStateDocs = <T extends any>(
23+
props: UseControllableStateDocsProps<T>
24+
) => null
25+
26+
export default {
27+
title: 'Hooks/useControllableState',
28+
component: UseControllableStateDocs,
29+
excludeStories: ['UseControllableStateDocs'],
30+
} as Meta
31+
32+
const Template: Story<UseControllableStateDocsProps<boolean>> = ({
33+
value,
34+
setValue,
35+
}) => {
36+
const [state, setState] = useControllableState(value, setValue, false)
37+
return (
38+
<CheckToken
39+
color="primary"
40+
selected={state}
41+
onClick={() => setState(!state)}
42+
>
43+
{value === undefined ? 'Uncontrolled' : 'Controlled'}
44+
</CheckToken>
45+
)
46+
}
47+
48+
export const Default = Template.bind({})
49+
Default.args = {}
50+
51+
export const Controlled = Template.bind({})
52+
Controlled.args = {
53+
value: true,
54+
setValue: action('setValue'),
55+
}
56+
Controlled.parameters = {
57+
docs: {
58+
description: {
59+
story:
60+
'This is a controlled example, clicking does not change the value but registers the click in the actions',
61+
},
62+
},
63+
}
+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { renderHook, act } from '@testing-library/react-hooks'
2+
import { useControllableState } from '.'
3+
4+
let spy: jest.SpyInstance<void, [message?: any, ...optionalParams: any[]]>
5+
interface TestArgs {
6+
value: boolean | undefined
7+
setValue: React.Dispatch<React.SetStateAction<boolean>> | undefined
8+
}
9+
10+
type TestReturn = [boolean, React.Dispatch<React.SetStateAction<boolean>>]
11+
12+
const value = true
13+
const altValue = false
14+
let setValue: jest.Mock<any, any>
15+
16+
beforeEach(() => {
17+
setValue = jest.fn()
18+
spy = jest.spyOn(console, 'warn').mockImplementation()
19+
})
20+
21+
afterEach(() => {
22+
spy.mockRestore()
23+
})
24+
25+
test('Should provide initial output as setState when uncontrolled', () => {
26+
const { result } = renderHook(() =>
27+
useControllableState(undefined, undefined)
28+
)
29+
expect(result.current[0]).toBeUndefined()
30+
expect(typeof result.current[1]).toBe('function')
31+
})
32+
33+
test('Should use default value if not controlled', () => {
34+
const { result } = renderHook(() =>
35+
useControllableState(undefined, undefined, value)
36+
)
37+
expect(result.current[0]).toBe(value)
38+
})
39+
40+
test('Should call default initializer not controlled', () => {
41+
const initializer = jest.fn().mockImplementation(() => value)
42+
43+
const { result } = renderHook(() =>
44+
useControllableState(undefined, undefined, initializer)
45+
)
46+
expect(result.current[0]).toBe(value)
47+
expect(initializer).toHaveBeenCalled()
48+
})
49+
50+
test('Should provide initial output as setState when controlled', () => {
51+
const { result } = renderHook(() => useControllableState(true, setValue))
52+
expect(result.current[0]).toBe(value)
53+
expect(typeof result.current[1]).toBe('function')
54+
})
55+
56+
test('Should not use initializer if controlled', () => {
57+
const { result } = renderHook(() =>
58+
useControllableState(value, setValue, false)
59+
)
60+
expect(result.current[0]).toBe(value)
61+
})
62+
63+
test('Should not call default initializer controlled', () => {
64+
const initializer = jest.fn().mockImplementation(() => value)
65+
66+
const { result } = renderHook(() =>
67+
useControllableState(value, setValue, initializer)
68+
)
69+
expect(result.current[0]).toBe(value)
70+
expect(initializer).not.toHaveBeenCalled()
71+
})
72+
73+
test('setValue should change value when uncontrolled', () => {
74+
const { result } = renderHook(() =>
75+
useControllableState<boolean | undefined>(undefined, undefined)
76+
)
77+
78+
const value = true
79+
80+
act(() => {
81+
result.current[1](value)
82+
})
83+
expect(result.current[0]).toEqual(value)
84+
})
85+
86+
test('setValue should call supplied function when controlled', () => {
87+
const { result } = renderHook(() =>
88+
useControllableState<boolean | undefined>(value, setValue)
89+
)
90+
91+
act(() => {
92+
result.current[1](altValue)
93+
})
94+
expect(result.current[0]).toEqual(value)
95+
expect(setValue).toHaveBeenCalledWith(altValue)
96+
})
97+
98+
test('should warn if switching from uncontrolled to controlled', () => {
99+
const { rerender } = renderHook<TestArgs, TestReturn>(
100+
({ value, setValue }) =>
101+
useControllableState<boolean>(value, setValue, false),
102+
{ initialProps: { value: undefined, setValue: undefined } }
103+
)
104+
105+
rerender({ value: value, setValue: setValue })
106+
expect(console.warn).toHaveBeenCalledTimes(1)
107+
})
108+
109+
test('should warn if switching from uncontrolled to controlled', () => {
110+
const { rerender } = renderHook<TestArgs, TestReturn>(
111+
({ value, setValue }) =>
112+
useControllableState<boolean>(value, setValue, false),
113+
{ initialProps: { value: value, setValue: setValue } }
114+
)
115+
116+
rerender({ value: undefined, setValue: undefined })
117+
expect(console.warn).toHaveBeenCalledTimes(1)
118+
})
119+
120+
test('Should not warn in production mode', () => {
121+
const previousEnv = process.env.NODE_ENV
122+
process.env.NODE_ENV = 'production'
123+
try {
124+
const { rerender } = renderHook<TestArgs, TestReturn>(
125+
({ value, setValue }) =>
126+
useControllableState<boolean>(value, setValue, false),
127+
{ initialProps: { value: value, setValue: setValue } }
128+
)
129+
130+
rerender({ value: undefined, setValue: undefined })
131+
expect(console.warn).toHaveBeenCalledTimes(0)
132+
} finally {
133+
process.env.NODE_ENV = previousEnv
134+
}
135+
})

Diff for: src/useControllableState/useControllableState.ts

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
3+
/** type guard to check if value or function */
4+
function isValue<T>(arg: T | (() => T | undefined)): arg is T {
5+
return typeof arg !== 'function'
6+
}
7+
8+
/** no operation */
9+
// eslint-disable-next-line @typescript-eslint/no-empty-function
10+
function noop() {}
11+
12+
/**
13+
* useControllableState hook for when the state may be controlled or uncontrolled.
14+
*
15+
* Returns as the standard useState hook, but has additional props of a controlled value and a controlled change handler.
16+
* Set these using the components incoming props for the state, if defined they will be used, if not you get the standard useState behaviour.
17+
*
18+
* @param {T | undefined} value The controlled value (of type T) or undefined for an uncontrolled value
19+
* @param {React.Dispatch<React.SetStateAction<T>> | undefined} setValue The dispatch handler for state changes or undefined for when an uncontrolled value, ignored if uncontrolled
20+
* @param {T | (() => T | undefined) | undefined} initialState The initial state value, or state initializer for when uncontrolled, ignored if controlled
21+
*/
22+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
23+
export function useControllableState<T = any>(
24+
value: T | undefined,
25+
setValue: React.Dispatch<React.SetStateAction<T>> | undefined,
26+
initialState?: T | (() => T | undefined) | undefined
27+
): [T, React.Dispatch<React.SetStateAction<T>>] {
28+
const { current: wasControlled } = useRef(value !== undefined)
29+
const isControlled = value !== undefined
30+
31+
const [uncontrolled, setUncontrolled] = useState<T | undefined>(() => {
32+
if (value === undefined) {
33+
if (initialState !== undefined) {
34+
return isValue(initialState) ? initialState : initialState()
35+
}
36+
}
37+
return undefined
38+
})
39+
let effect = noop
40+
if (process.env.NODE_ENV !== 'production') {
41+
effect = function () {
42+
if (wasControlled !== isControlled) {
43+
console.warn(
44+
'Components should not switch from uncontrolled to controlled (or vice versa)'
45+
)
46+
}
47+
}
48+
}
49+
useEffect(effect, [isControlled])
50+
51+
return [
52+
(wasControlled ? value : uncontrolled) as T,
53+
(wasControlled ? setValue : setUncontrolled) as React.Dispatch<
54+
React.SetStateAction<T>
55+
>,
56+
]
57+
}

Diff for: src/useDebounce/useDebounce.stories.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ export interface UseDebounceDocsProps<T> {
3030
* Adapted from <https://usehooks.com/>, if more functionality or control is required use [use-debounce](https://github.com/xnimorz/use-debounce).
3131
*
3232
*/
33-
export const UseDebounceDocs: React.FC<UseDebounceDocsProps<any>> = () => null
33+
export const UseDebounceDocs = <T extends any>(
34+
props: UseDebounceDocsProps<T>
35+
) => null
3436

3537
export default {
3638
title: 'Hooks/useDebounce',

Diff for: src/useDebounce/useDebounce.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { useEffect, useState } from 'react'
99
*
1010
* Adapted from <https://usehooks.com/>.
1111
*
12-
* @param value The value to debounce updates for
13-
* @param delay The time to delay updates by (ms)
12+
* @param {T} value The value to debounce updates for
13+
* @param { number | null} delay The time to delay updates by (ms)
1414
*/
1515
export function useDebounce<T>(
1616
value: T,

Diff for: src/useEventListener/useEventListener.stories.tsx

+4-3
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export interface UseEventListenerDocsProps<
1010
eventName: string
1111
/** the callback function to call on the event firing */
1212
handler: ((event: Event) => void) | null
13-
/** (optional) reference for the element to add the listener too */
13+
/** (optional) reference for the element to add the listener to T extends `HTMLElement` and defaults to `HTMLDivElement` */
1414
element?: RefObject<T>
1515
}
1616

@@ -27,8 +27,9 @@ export interface UseEventListenerDocsProps<
2727
* @param handler the callback function
2828
* @param element (optional) reference for the element
2929
*/
30-
export const UseEventListenerDocs: React.FC<UseEventListenerDocsProps> = () =>
31-
null
30+
export const UseEventListenerDocs = <T extends HTMLElement>(
31+
props: UseEventListenerDocsProps<T>
32+
) => null
3233

3334
export default {
3435
title: 'Hooks/useEventListener',

Diff for: src/useHover/useHover.stories.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Box } from '@committed/components'
44
import { useHover } from '.'
55

66
export interface UseHoverDocsProps<T extends HTMLElement> {
7-
/** element reference to track hover on */
7+
/** element reference to track hover on, T extends `HTMLElement` */
88
element?: RefObject<T>
99
}
1010

@@ -13,7 +13,9 @@ export interface UseHoverDocsProps<T extends HTMLElement> {
1313
*
1414
* @param element reference for the element to add the listener too
1515
*/
16-
export const UseHoverDocs: React.FC<UseHoverDocsProps<HTMLElement>> = () => null
16+
export const UseHoverDocs = <T extends HTMLElement>(
17+
props: UseHoverDocsProps<T>
18+
) => null
1719

1820
export default {
1921
title: 'Hooks/useHover',

Diff for: src/useKeyboard/useKeyboard.stories.tsx

+5-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import React, { useRef } from 'react'
1414
import { KEYBOARD_MODIFIERS, Keys, useKeyboard } from '.'
1515
import { useBoolean } from '../useBoolean/useBoolean'
1616

17-
export interface UseKeyboardDocsProps<T> {
17+
export interface UseKeyboardDocsProps<T extends HTMLElement = HTMLDivElement> {
1818
/** The definition of the key filter.
1919
*
2020
* The basic definition is a string filter separated with the `+` e.g. `'a'` or `'ctrl+a'`
@@ -29,7 +29,7 @@ export interface UseKeyboardDocsProps<T> {
2929
handler: ((event: KeyboardEvent) => void) | null
3030
/** Options
3131
*
32-
* - __element__ provide a ref for the element to bind to (defaults to `window`)
32+
* - __element__ provide a ref for the element to bind to (defaults to `window`) (T extends `HTMLElement`)
3333
* - __event__ the key event to listen to (defaults to `keydown`)
3434
* - __ignoreKey__ set `true` to turn off the `KeyCode` test no other match (defaults to `false`)
3535
* - __ignoreRepeat__ set `true` to ignore repeat events (defaults to `false`)
@@ -45,7 +45,9 @@ export interface UseKeyboardDocsProps<T> {
4545
/**
4646
* useKeyboard hook to add a callback to be called on the use of the keyboard under specified circumstances.
4747
*/
48-
export const UseKeyboardDocs: React.FC<UseKeyboardDocsProps<any>> = () => null
48+
export const UseKeyboardDocs = <T extends HTMLElement = HTMLDivElement>(
49+
props: UseKeyboardDocsProps<T>
50+
) => null
4951

5052
export default {
5153
title: 'Hooks/useKeyboard',

Diff for: src/useLocalState/useLocalState.stories.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ export interface UseLocalStateDocsProps<T> {
1919
* useLocalState hook behaves like `React.useState`, returning the state and a function to set the value.
2020
* In addition, the value is put in local storage against the given key and is persisted through page refresh.
2121
*/
22-
export const UseLocalStateDocs: React.FC<UseLocalStateDocsProps<any>> = (
23-
_props: UseLocalStateDocsProps<any>
22+
export const UseLocalStateDocs = <T extends any>(
23+
props: UseLocalStateDocsProps<T>
2424
) => null
2525

2626
export default {

Diff for: src/useTrackedState/useTrackedState.stories.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ export interface UseTrackedStateDocsProps<T> {
1414
*
1515
* @param initialState (optional) starting state or function to provide starting state
1616
*/
17-
export const UseTrackedStateDocs: React.FC<UseTrackedStateDocsProps<
18-
any
19-
>> = () => null
17+
export const UseTrackedStateDocs = <T extends any>(
18+
props: UseTrackedStateDocsProps<T>
19+
) => null
2020

2121
export default {
2222
title: 'Hooks/useTrackedState',

0 commit comments

Comments
 (0)