UNPKG

6.02 kBJavaScriptView Raw
1'use client';
2
3import _extends from "@babel/runtime/helpers/esm/extends";
4import * as React from 'react';
5function areEqual(a, b) {
6 return a === b;
7}
8const EMPTY_OBJECT = {};
9const NOOP = () => {};
10
11/**
12 * Gets the current state augmented with controlled values from the outside.
13 * If a state item has a corresponding controlled value, it will be used instead of the internal state.
14 */
15function getControlledState(internalState, controlledProps) {
16 const augmentedState = _extends({}, internalState);
17 Object.keys(controlledProps).forEach(key => {
18 if (controlledProps[key] !== undefined) {
19 augmentedState[key] = controlledProps[key];
20 }
21 });
22 return augmentedState;
23}
24/**
25 * Defines an effect that compares the next state with the previous state and calls
26 * the `onStateChange` callback if the state has changed.
27 * The comparison is done based on the `stateComparers` parameter.
28 */
29function useStateChangeDetection(parameters) {
30 const {
31 nextState,
32 initialState,
33 stateComparers,
34 onStateChange,
35 controlledProps,
36 lastActionRef
37 } = parameters;
38 const internalPreviousStateRef = React.useRef(initialState);
39 React.useEffect(() => {
40 if (lastActionRef.current === null) {
41 // Detect changes only if an action has been dispatched.
42 return;
43 }
44 const previousState = getControlledState(internalPreviousStateRef.current, controlledProps);
45 Object.keys(nextState).forEach(key => {
46 // go through all state keys and compare them with the previous state
47 const stateComparer = stateComparers[key] ?? areEqual;
48 const nextStateItem = nextState[key];
49 const previousStateItem = previousState[key];
50 if (previousStateItem == null && nextStateItem != null || previousStateItem != null && nextStateItem == null || previousStateItem != null && nextStateItem != null && !stateComparer(nextStateItem, previousStateItem)) {
51 onStateChange?.(lastActionRef.current.event ?? null, key, nextStateItem, lastActionRef.current.type ?? '', nextState);
52 }
53 });
54 internalPreviousStateRef.current = nextState;
55 lastActionRef.current = null;
56 }, [internalPreviousStateRef, nextState, lastActionRef, onStateChange, stateComparers, controlledProps]);
57}
58
59/**
60 * The alternative to `React.useReducer` that lets you control the state from the outside.
61 *
62 * It can be used in an uncontrolled mode, similar to `React.useReducer`, or in a controlled mode, when the state is controlled by the props.
63 * It also supports partially controlled state, when some state items are controlled and some are not.
64 *
65 * The controlled state items are provided via the `controlledProps` parameter.
66 * When a reducer action is dispatched, the internal state is updated with the new values.
67 * A change event (`onStateChange`) is then triggered (for each changed state item) if the new state is different from the previous state.
68 * This event can be used to update the controlled values.
69 *
70 * The comparison of the previous and next states is done using the `stateComparers` parameter.
71 * If a state item has a corresponding comparer, it will be used to determine if the state has changed.
72 * This is useful when the state item is an object and you want to compare only a subset of its properties or if it's an array and you want to compare its contents.
73 *
74 * An additional feature is the `actionContext` parameter. It allows you to add additional properties to every action object,
75 * similarly to how React context is implicitly available to every component.
76 *
77 * @template State - The type of the state calculated by the reducer.
78 * @template Action - The type of the actions that can be dispatched.
79 * @template ActionContext - The type of the additional properties that will be added to every action object.
80 *
81 * @ignore - internal hook.
82 */
83export function useControllableReducer(parameters) {
84 const lastActionRef = React.useRef(null);
85 const {
86 reducer,
87 initialState,
88 controlledProps = EMPTY_OBJECT,
89 stateComparers = EMPTY_OBJECT,
90 onStateChange = NOOP,
91 actionContext,
92 componentName = ''
93 } = parameters;
94 const controlledPropsRef = React.useRef(controlledProps);
95 if (process.env.NODE_ENV !== 'production') {
96 // eslint-disable-next-line react-hooks/rules-of-hooks
97 React.useEffect(() => {
98 Object.keys(controlledProps).forEach(key => {
99 if (controlledPropsRef.current[key] !== undefined && controlledProps[key] === undefined) {
100 console.error(`useControllableReducer: ${componentName ? `The ${componentName} component` : 'A component'} is changing a controlled prop to be uncontrolled: ${key}`);
101 }
102 if (controlledPropsRef.current[key] === undefined && controlledProps[key] !== undefined) {
103 console.error(`useControllableReducer: ${componentName ? `The ${componentName} component` : 'A component'} is changing an uncontrolled prop to be controlled: ${key}`);
104 }
105 });
106 }, [controlledProps, componentName]);
107 }
108
109 // The reducer that is passed to React.useReducer is wrapped with a function that augments the state with controlled values.
110 const reducerWithControlledState = React.useCallback((state, action) => {
111 lastActionRef.current = action;
112 const controlledState = getControlledState(state, controlledProps);
113 const newState = reducer(controlledState, action);
114 return newState;
115 }, [controlledProps, reducer]);
116 const [nextState, dispatch] = React.useReducer(reducerWithControlledState, initialState);
117
118 // The action that is passed to dispatch is augmented with the actionContext.
119 const dispatchWithContext = React.useCallback(action => {
120 dispatch(_extends({}, action, {
121 context: actionContext
122 }));
123 }, [actionContext]);
124 useStateChangeDetection({
125 nextState,
126 initialState,
127 stateComparers: stateComparers ?? EMPTY_OBJECT,
128 onStateChange: onStateChange ?? NOOP,
129 controlledProps,
130 lastActionRef
131 });
132 return [getControlledState(nextState, controlledProps), dispatchWithContext];
133}
\No newline at end of file