Effect for Reducers in React

Effect for Reducers in React

Riffing off of my last post, I thought I'd refactor the async side of my helper to use a reducer, and try to leverage Effect's Tagged Enum to create and use discriminated unions, which lends itself very nicely to reducers.

Turns out this is like, crazy easy and crazy good.

What shall we make?
We're going to keep it simple today, let's make a flip-flop component that bounces between a success and a failure state.

LFGO

Declare actions as a discrimnated union and get the constructors and exhaustive matcher in one additional line of code from Effect - solid start:

type Actions<Result, Error> = Data.TaggedEnum<{
  Begin: {};
  Succeed: { readonly result: Result };
  Fail: { readonly error: Error };
}>;

// Effect can generate the action creators (constructors) + matchers:
const { Begin, Succeed, Fail, $match } = Data.taggedEnum<Actions<any, any>>();

// mind the 'any' in the generics, we'll get back to them later

Now let's declare our state:

type State = Data.TaggedEnum<{
  Empty: {};
  Failed: { readonly errorMessage: string };
  Completed: { readonly answer: number };
}>;

// we'll have to alias $match
const {Empty, Failed, Completed, $match: matchState } = Data.taggedEnum<State>();

const INITIAL_STATE = Empty();

Feels good - more discriminated unions to express and model our reality. Cozy.

Now let's declare the reducer. React comes with a React.Reducer type that can guide our implementation:

const reducer: React.Reducer<State, Actions<any, any>> = (state, action) => {
  const matcher = $match({
    // MAGIC
  });
  
  return matcher(action);
}

If you implement the above yourself, you'll see the magic - auto complete for handling all actions. Anytime we add a new action or change the shape of state we get immediate feedback - nice! And because state is modelled as an enum, we can ignore the first parameter in the reducer and just use the constructors to create new objects, respecting the rules of react (new references, no mutations) needed to use reducers:

const reducer: React.Reducer<State, Actions<any, any>> = (_state, action) => {
  const matcher = $match({
    Begin: (_) => Empty(),
    Succeed: ({ result }) => Completed({ answer: 42 }),
    Fail: ({ error }) => Failed({ errorMessage: String(error) })
  });
  
  return matcher(action);
}

Our matcher has nothing to do with state or action, so it could be pulled out, which is always a nice perf win as reducers get larger.

const actionToStateMatcher = $match({
  Begin: (_) => Empty(),
  Succeed: ({ result }) => Completed({ answer: 42 }),
  Fail: ({ error }) => Failed({ errorMessage: String(error) })
}); 

const reducer = (_state: State, action: Actions<any, any>) =>
  actionToStateMatcher(action);

useReducer like it's 1995

With a quick script to continually dispatch various actions, we can use the matchState function we aliased from our State's $match to render our UI.

const useFlipFlop = (dispatch: React.Dispatch<Actions<any, any>>) => {
  useEffect(() => {
    dispatch(Begin());
    let flip = true;
    
    const interval = setInterval(() => {
      dispatch(flip ? Succeed({ result: "flip" }) : Fail({ error: "flop" }));
      
      flip = !flip;
    }, 600);
    
    return () => clearInterval(interval);
  });
}

const App = () => {
  const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
  
  useFlipFlop(dispatch);

  return matchState({
    Empty: () => <div>empty</div>,
    Failed: ({ errorMessage }) => <div>Error: {errorMessage}</div>,
    Completed: ({ answer }) => <h1>{answer}</h1>
  })(state);
}

If any branch gets a bit too complex, and is independent of the State Enum case given, we can extract it into a component:

// built in union extraction!
type CompletedCase = Data.TaggedEnum.Value<State, "Completed">;

const DisplayCompleted = ({ answer }: CompletedCase) =>
  <h1 className="text-2xl"><span>{answer}</span></h1>;

const App = () => {
  const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
  
  useFlipFlop(dispatch)

  // imagine we had the others defined as well
  return matchState({
    Empty: DisplayEmpty,
    Failed: DisplayFailed,
    Completed: DisplayCompleted
  })(state);
}

This matcher is also now independent, and can be extracted as well.

const StateMatcher = matchState({
  Empty: DisplayEmpty,
  Failed: DisplayFailed,
  Completed: DisplayCompleted
});

const App = () => {
  const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
  
  useFlipFlop(dispatch);

  // starting to look like Functions of State -> UI
  return StateMatcher(state);
}

The code the above example is here: https://github.com/snewell92/explore-effect/blob/main/src/FlipFlip.tsx - commit at time of publishing was 5ab825.

Generics

You can use the generics with an interface, the docs show it well; for our example I'd go for something like:

// what a G
interface ActionsG extends Data.TaggedEnum.WithGenerics<2> {
  readonly taggedEnum: Actions<this["A"], this["B"]>;
}

// now the generic Result/Error flows into Succeed & Fail
const { Begin, Succeed, Fail, $match } = Data.taggedEnum<ActionsG>();

That, hopefully, will be all you need to declare and use for a regular app with a key reducer defined and armed to the teeth with full type safety.

While using this to build generic functions, I've found 'pinning' the generics with the original type seems to help, YMMV.

// take in specific constraints and bind them to the tagged Enum's functions

/** Create a strongly typed match constructor for any collapse */
export function createActionsMatch<Result, Error>() {
  const pinnedEnum = Data.taggedEnum<Actions<Result, Error>>();
  return pinnedEnum.$match;
}

// now when you match you'll be able to weave and compose the given matcher
// in a generic function more easily
export const useSomeGenericHook = <TResult, TError>() => {
  // pin the types so we can have exhaustive checking for cases
  const matcher = useCallback(
    // expand/transform the generics as well, here we just expand errors w/ strings
    createActionsMatch<TResult, TError | string>(),
    []
  );

  // another helper, extracting the case
  type CollapseMatcher = FirstParam<typeof matcher>;

  const match = <Cases extends CollapseMatcher>(cases: Cases) =>
    matcher(cases)(collapsed);

  // you can then have a hook return just the match function
  // which will give rich auto complete + type checking 
  return match;
};

I use a generic tagged enum in my "ReEffect" library to encode a generic Success or Failure for an executed Effect to be used in either a useSync or usePromise effect - link to the sauce. This affords a great sense of type safety, I'm still actively exploring this though, let me know what you think! <3