Heyyy Marco
Heyyy Marco

Reputation: 753

How to create a custom useReducer with built in decision and event triggering

I want to create a custom hook, let's say useTextProcessor(initialText, props).
It's a react state for storing & manipulating text (string).
It uses useReducer for making cumulative state.
The code is like this:

interface TextEditorProps {
  disabled?: boolean
  onTextChanged?: (text: string) => void
}

const useTextProcessor = (initialText: string, props: TextEditorProps) => {
  const [state, dispatch] = useReducer(textReducerFunc, /*initialState: */{
    text  : initialText,
    props : props, // HACK: a dependency is injected here
  });
  state.props = props; // HACK: a dependency is updated here

  return [
    state.text,
    dispatch
  ] as const;
}

There is a hack way to inject props to be accessed in textReducerFunc.
The textReducerFunc is the main function where the text is processed (depended on the action type & props state).

I've no idea how to insert the dependency props into textReducerFunc in professional react way.
If I declared the textReducerFunc inside the useTextProcessor(initialText, props),
yes I can access the props, but wait since it's a child function, the child always be re-created every useTextProcessor called.
This makes useReducer executing the textReducerFunc twice on the next render.
Ut's making onTextChanged will be executed twice too.
Wrapping with useCallback will not have any effect since onTextChanged accepts any function (might be a static or inline function). The inline function is always different by reference on every render.

Here the detail of textReducerFunc works:

interface TextState {
  props: TextEditorProps // holds my hack
  text: string // the actual data to be processed
}
interface TextAction {
  type: 'APPEND'|'UPPERCASE'
  payload?: string
}
const textReducerFunc = (state: TextState, action: TextAction) => {
  const props = state.props;
  if (props.disabled) return state; // disabled => no change

  switch(action.type) {
    case 'APPEND':
      const appendText = state.text + action.payload;
      props.onTextChanged?.(appendText); // notify the text has been modified
      return {...state, text: appendText};

    case 'UPPERCASE':
      const upperText = state.text.toUpperCase();
      props.onTextChanged?.(upperText); // notify the text has been modified
      return {...state, text: upperText};

    default:
      return state; // unknown type => no change
  } // switch
}

The usage of useTextProcessor:

export default function TxtEditor(props: TextEditorProps) {
  const [text, dispatch] = useTextProcessor('hello', props);

  return (
    <div>
      <div>
        {text}
      </div>
      <button onClick={() => dispatch({type: 'APPEND', payload: ' world'})}>append 'world'</button>
      <button onClick={() => dispatch({type: 'UPPERCASE'})}>uppercase</button>
    </div>
  )
}

Can you suggest to me how to use useReducer with dependency without any hack?

Here my running sandbox

Upvotes: 0

Views: 802

Answers (1)

Elias
Elias

Reputation: 4122

I would recommend elevating the state into the parent component. You need an "onTextChange" listener. That tells me that the state is in the wrong component. I recommend putting it into the parent component.

type TextAction =
  | { type: 'APPEND'; payload?: string }
  | { type: 'UPPERCASE' }
  | { type: 'SET_DISABLED'; disabled: boolean };

interface TextState {
  text: string;
  disabled: boolean;
}

const textReducerFunc = (state: TextState, action: TextAction): TextState => {
  console.log(action);

  switch (action.type) {
    case 'APPEND': {
      if (state.disabled) return state;
      return { ...state, text: state.text + action.payload };
    }
    case 'UPPERCASE': {
      if (state.disabled) return state;
      return { ...state, text: state.text.toLocaleUpperCase() };
    }
    case 'SET_DISABLED':
      return { ...state, disabled: action.disabled };
  }
};

function useTextReducer() {
  return useReducer(textReducerFunc, {
    disabled: false,
    text: ''
  });
}

function TextBox({
  text,
  dispatch
}: {
  text: string;
  dispatch: Dispatch<TextAction>;
}) {
  return (
    <React.Fragment>
      <div>{text}</div>
      <button onClick={handleAppendPress}>append 'world'</button>
      <button onClick={handleUppercaseClick}>uppercase</button>
    </React.Fragment>
  );

  function handleAppendPress() {
    dispatch({ type: 'APPEND', payload: 'world' });
  }

  function handleUppercaseClick() {
    dispatch({ type: 'UPPERCASE' });
  }
}

function App() {
  const [state, dispatch] = useTextReducer();
  const { text } = state;

  useEffect(() => {
    console.log(`the text changed: ${text}`);
  }, [text]);

  return (
    <React.Fragment>
      <label>is disabled: </label>
      <input
        checked={state.disabled}
        type="checkbox"
        onChange={handleDisabledChange}
      />
      <TextBox dispatch={dispatch} text={state.text} />
    </React.Fragment>
  );

  function handleDisabledChange() {
    dispatch({ type: 'SET_DISABLED', disabled: !state.disabled });
  }
}

(code here)

From what I see in your code (the buttons that are already in it) you probably want to move the "disabled" checkbox into the TextBox component, further removing redundancy in state setting.

Upvotes: 1

Related Questions