Skip to content

Commit

Permalink
feat(hooks): update useCallbackRef and useControllable hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
HytonightYX committed Sep 15, 2022
1 parent b279364 commit f89dae5
Show file tree
Hide file tree
Showing 2 changed files with 46 additions and 60 deletions.
15 changes: 15 additions & 0 deletions packages/core/src/hooks/use-callback-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { DependencyList } from 'react';
import { useCallback, useEffect, useRef } from 'react';

/**
* @see https://github.com/theKashey/use-callback-ref
*/
export function useCallbackRef<T extends (...args: any[]) => any>(callback: T | undefined, deps: DependencyList = []) {
const callbackRef = useRef(callback);

useEffect(() => {
callbackRef.current = callback;
});

return useCallback(((...args) => callbackRef.current?.(...args)) as T, deps);
}
91 changes: 31 additions & 60 deletions packages/core/src/hooks/use-controllable.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,47 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { runIfFn, warn } from '../utils';
import { useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { useCallbackRef } from './use-callback-ref';

export function useControllableProp<T>(prop: T | undefined, state: T) {
const { current: isControlled } = useRef(prop !== undefined);
const value = isControlled && typeof prop !== 'undefined' ? prop : state;
return [isControlled, value] as const;
const controlled = typeof prop !== 'undefined';
const value = controlled ? prop : state;
return useMemo<[boolean, T]>(() => [controlled, value], [controlled, value]);
}

const defaultPropsMap = {
value: 'value',
defaultValue: 'defaultValue',
onChange: 'onChange',
};

export interface UseControllableProps<T> {
value: T;
defaultValue: T;
onChange?: (nextValue: T) => void;
name?: string;
propsMap?: {
value: string;
defaultValue: string;
onChange: string;
};
export interface UseControllableStateProps<T> {
value?: T;
defaultValue?: T | (() => T);
onChange?: (value: T) => void;
shouldUpdate?: (prev: T, next: T) => boolean;
}

export function useControllableState<T>(props: UseControllableProps<T>) {
const { value: valueProp, defaultValue, onChange, name = 'Component', propsMap = defaultPropsMap } = props;

const [valueState, setValue] = useState(defaultValue as T);
const { current: isControlled } = useRef(valueProp !== undefined);

useEffect(() => {
const nextIsControlled = valueProp !== undefined;
export function useControllableState<T>(props: UseControllableStateProps<T>) {
const { value: valueProp, defaultValue, onChange, shouldUpdate = (prev, next) => prev !== next } = props;

const nextMode = nextIsControlled ? 'a controlled' : 'an uncontrolled';
const mode = isControlled ? 'a controlled' : 'an uncontrolled';
const onChangeProp = useCallbackRef(onChange);
const shouldUpdateProp = useCallbackRef(shouldUpdate);

warn({
condition: isControlled !== nextIsControlled,
message:
`Warning: ${name} is changing from ${mode} to ${nextMode} component. ` +
`Components should not switch from controlled to uncontrolled (or vice versa). ` +
`Use the '${propsMap.value}' with an '${propsMap.onChange}' handler. ` +
`If you want an uncontrolled component, remove the ${propsMap.value} prop and use '${propsMap.defaultValue}' instead. "` +
`More info: https://fb.me/react-controlled-components`,
});
}, [valueProp, isControlled, name]);
const [uncontrolledState, setUncontrolledState] = useState(defaultValue as T);
const controlled = valueProp !== undefined;
const value = controlled ? valueProp : uncontrolledState;

const { current: initialDefaultValue } = useRef(defaultValue);
const setValue = useCallbackRef(
(next: SetStateAction<T>) => {
const setter = next as (prevState?: T) => T;
const nextValue = typeof next === 'function' ? setter(value) : next;

useEffect(() => {
warn({
condition: initialDefaultValue !== defaultValue,
message:
`Warning: A component is changing the default value of an uncontrolled ${name} after being initialized. ` +
`To suppress this warning opt to use a controlled ${name}.`,
});
}, [JSON.stringify(defaultValue)]);

const value = isControlled ? (valueProp as T) : valueState;
if (!shouldUpdateProp(value, nextValue)) {
return;
}

const updateValue = useCallback(
(next) => {
const nextValue = runIfFn(next, value);
if (!isControlled) {
setValue(nextValue);
if (!controlled) {
setUncontrolledState(nextValue);
}
onChange?.(nextValue);

onChangeProp(nextValue);
},
[isControlled, onChange, value],
[controlled, onChangeProp, value, shouldUpdateProp],
);

return [value, updateValue] as [T, React.Dispatch<React.SetStateAction<T>>];
return [value, setValue] as [T, Dispatch<SetStateAction<T>>];
}

0 comments on commit f89dae5

Please sign in to comment.