Skip to content

Fun With React Hooks

Posted on:January 14, 2023 at 01:00 PM

Warning: Your definition of fun may differ to mine.

The problem

I wanted to write a custom logger where it enables you to accumulate data throughout the application/component lifecycles, but have it be tucked away so that you weren’t constantly having to rebuild the data you already have (and is still valid). Maybe something like…

const logger = new Logger({});
logger.add({ some_data: "hello" });
logger.add({ something_else: 42 });
logger.send();

in the OOP world. You could probably make this work in a React app, but then you’re in the global variable/singletons realm and tbh I didn’t want to go there. [Edit: I’m basically describing a React Context here!]

‘Ah!’ you say, ‘you should create a logger context and wrap your application with the associated providers!’

Yes, I should. So I did. But there still isn’t yet a way to add/remove/invalidate/send data within that context.

Custom context backed by useState

Usually with a custom context and provider component, a developer will provide a custom hook that, provides the data, and gives the consumer a way to modify the data. I’ve seen this done with good ol’ useState (docs), which might look something like this (ignoring the gritty details…)

const CustomContext = createContext([{}, _ => {}]);

function CustomProvider({ children }) {
  const stateAndSetter = useState({});

  return (
    <CustomContext.Provider value={stateAndSetter}>
      {children}
    </CustomContext.Provider>
  );
}

function useCustom() {
  const context = useContext(CustomContext);
  if (!context) {
    // Throw toys
  }

  return context;
}

// Then, in one of the children components you have access to stateAndSetter
function SomeChildComponent() {
  const [custom, setCustom] = useCustom();
  // ...
}

But this pattern isn’t suitable for my custom logger. It could be made to work, but will needlessly impact the performance of your application. A usage of this solution might look like this:

function SomeComponent() {
  const [logs, setLogs] = useState({});

  function someCallback() {
    setLogs(logs => { ...logs, some_data: 'hello' });
    // ...
  }

  // ...
}

When the setter is called, React will re-render SomeComponent - but the component doesn’t depend on the logs (I hope!), so we shouldn’t have to re-render to add data to the logs. What I’m really saying (and what React is trying to tell you) is that your logs aren’t a part of your application state. This is one of the things React talks about in thinking in React.

Custom context backed by useRef

useRef (docs) is a React hook which is fairly similar to useState in that you can use it to (kind of) keep track of state in your component/application. The thing which sets it apart from useState, and the thing that made it the hook I reached for, is that when you update your ref, it doesn’t trigger a re-render. Wonderful! This feels like the right choice for my logger. I can accumulate data in a ref throughout my component’s lifecycle, without it impacting its rendering. For completeness, a usage might look like (this is very similar to the initial useState attempt…)

const CustomContext = createContext([{}, _ => {}]);

function CustomProvider({ children }) {
  const customRef = useRef({ hello: "world!" });

  return (
    <CustomContext.Provider value={customRef}>
      {children}
    </CustomContext.Provider>
  );
}

function useCustom() {
  // Consider that mebbe the hook was invoked incorrectly, yadda yadda
  return useContext(CustomContext);
}

function SomeChildComponent() {
  const custom = useCustom();

  function someCallback() {
    custom.current = { ...custom.current, some_data: "hello" };
  }

  // ...
}

Hmmm… another difference between useState and useRef is the API you use to update the values. As you can see in someCallback, refs don’t really have a nice API for updating their contents. Which brings us to…

Custom context backed by useReducer (but not really)

I’m a big fan of useReducer (docs). It’s isomorphic to the useState hook, in the sense that it’s always possible to rewrite usages of useState with useReducer, and vice versa. I like useReducer because instead of explicitly passing your new desired state to some function (like you would with setState(...)), you describe how you want your state to update. A cheap (and suitably contrived) example:

function reducer(state, action) {
  switch (action.type) {
    case "ADD": {
      return state.count + action.amount;
    }
    case "SUBTRACT": {
      return state.count - action.amount;
    }
    // ...
  }
}

function SomeComponent() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });

  // update state to 1
  dispatch({ type: "ADD", amount: 1 });

  // update state to -9
  dispatch({ type: "SUBTRACT", amount: 10 });
}

You get the idea. The use case for useReducer is generally when your component state is starting to get tangled and complicated.

I wanted to use useReducer for my logger so that I can do stuff like

loggerDispatch({ type: "ADD_DATA", data: { some_data: "hello" } });
loggerDispatch({ type: "REMOVE_DATA", fields: ["some_data"] });

Or even

loggerDispatch({ type: "SEND_DATA", dataMask: "current_page" });

Or possibly even

loggerDispatch({ type: "ADD_DATA", data: { some_data: "hello" }, send: true });

Except useReducer maintains the state in useState-like fashion, where if I dispatch an action, it will re-render the component. Boo!

Custom context backed by useRefReducer

Surprisingly, according to my 15 second Google search^, a useReducer hook that uses refs in place of state doesn’t exist. I suspect this is because of some fatal reason I absolutely shouldn’t have done this. But who cares! So I wrote my own custom, totally generic and reusable React hook. Here it is, in its fully typed glory:

import { useRef } from "react";

export type RefReducerDispatch<A> = (action: A) => void;

export const useRefReducer = <S, A>(
  reducer: (state: S, action: A) => S,
  initialState: S
): [S, RefReducerDispatch<A>] => {
  const ref = useRef(initialState);

  const update: RefReducerDispatch<A> = action => {
    ref.current = reducer(ref.current, action);
  };

  return [ref.current, update];
};

And a (partial) implementation:

const LoggerContext = createContext<
  [NonNestedJson, RefReducerDispatch<Action>]
>([{}, _ => {}]);

export const LoggerProvider = ({
  children,
}: {
  children: JSX.Element | JSX.Element[];
}): JSX.Element => {
  const [state, dispatch] = useRefReducer(reducer, {});

  return (
    <LoggerContext.Provider value={[state, dispatch]}>
      {children}
    </LoggerContext.Provider>
  );
};

And utilisation:

export const SomePage = () => {
  const [, loggerDispatch] = useLogger();

  useEffect(() => {
    loggerDispatch({
      type: "ADD_DATA",
      data: { sales_channel: "RETAIL" },
      mask: "SOME_PAGE",
      send: true,
    });
  }, [loggerDispatch]);
};

Conclusion

This code will never go to production - but that’s okay because I had (a lot) of fun on this journey to creating a generic custom hook of my very own. I learned more about state, refs, contexts, reducers, hooks, React, … which I know will help me when I’m working on React apps.

^ You know, the kind of Google search you do where you don’t care if it’s a solved problem and you just want to solve it for yourself anyway?