Redux & Saga

Status: it's complicated

Managing your large web app's code is no easy feat, which is especially true for handling the state. Thanks to React's vast ecosystem, choosing the library (or none at all) that best fits your needs is both a joy and a pain. Today we'll look at a little piece of code in Typescript we're using when implementing Redux for state management. Some lines down this article I'll show you a helper-function for Saga, the side-effect-controlling-awesome-library we're using in conjunction with Redux. This article focuses on improving the action-usage in your React-components.

Let's take a look!

Code, with some extra code, please!

The first snippet below shows three function definitions that each simplify the usage of Redux-actions in your frontend. They're practically working like factories, returning hooks that can be used in React components. If this code looks odd, that's fine. We'll see it in action (pun intended) in a few moments.

/**
 * Get a generic hook to access a given slice of the AppStore,
 * selected by the key provided.
 *
 * @param key
 */
export function getConnectedStore<K extends keyof AppStore>(key: K) {
  return () => useSelector((s: AppStore) => s[key], shallowEqual);
}

/**
 * Get a generic hook-function to use with Action/Payload-types.
 * Removes overhead of manually writing out hooks for actions.
 */
export function getConnectedActionHook<A, P>() {
  return <Ax extends A, Px extends P>(action: (p: Px) => Ax) => {
    const dispatch = useDispatch();
    return useCallback((p: Px) => dispatch(action(p)), [dispatch, action]);
  };
}

/**
 * Get a generic hook-function to use with Actions only without
 * a payload.
 */
export function getConnectedEmptyActionHook<A>() {
  return <Ax extends A>(action: () => Ax) => {
    const dispatch = useDispatch();
    return useCallback(() => dispatch(action()), [dispatch, action]);
  };
}

The next piece is just a bare-bones type definition of one arbitrary reducer. I'll stick with a simple UserStore for this example, defining just what's needed to make UserStore usable by a plain old update-action-definition. Important are the last lines of code, where we define a Typescript Union. Each action/payload-definition has to be appended to let Typescript later infer that the actions/payloads defined here are, in fact, from UserStore.

export const UPDATE = "user/update";

export interface UserStore {
  name: string;
}

/*
 *
 * Actions.
 *
 */

export interface UpdateUserStoreAction {
  type: typeof UPDATE;
  payload: UpdateUserStorePayload;
}

export type UpdateUserStorePayload = Partial<UserStore>;

/*
 *
 * Union Action.
 *
 */

export type UserStoreAction =
  | UpdateUserStoreAction

export type UserStorePayload =
  | UpdateUserStorePayload

Alright, next snippet won't surprise you much, as it's only a standard action-function, using the types defined before.

// ... imports ...

/*
 *
 * Actions.
 *
 */

export const updateUserAction = (payload: UpdateUserStorePayload): UpdateUserStoreAction => ({
  type: UPDATE,
  payload,
});

// ... other actions ...

Here comes the last piece of the puzzle necessary to make it all work. We not only export our well-known root-reducer, which is the result of Redux' combineReducers, but also the ReturnType to get a type definition that covers all reducer-stores. In our example, that's only UserStore.

// ... imports ...

const reducer = combineReducers({
  user,
  ... other reducers ...
});

/*
 *
 * Exports.
 *
 */

export default reducer;
export type AppStore = ReturnType<typeof reducer>;

Finally, we can make use of getConnectedStore et al. for creating hooks that have access to the UserStore respectively can dispatch actions for it. We define these hooks in a separate file, from where they can be consumed in the components. This saves us the overhead of writing hooks for Redux for every single action.

// ... imports ...

export const useConnectedUserStore = getConnectedStore("user");
export const useConnectedUserAction = getConnectedActionHook<UserStoreAction, UserStorePayload>();
export const useConnectedUserEmptyAction = getConnectedEmptyActionHook<UserStoreAction>();

// ... somewhere in a lonely component ...

const View: FC = () => {
  const store = useConnectedUserStore();
  const update = useConnectedUserAction(updateUserStoreAction)
  
  return <div>...</div>;
}

Something Saga

Regarding Saga I can show you a little wrapper-function that helps us keep things a little more DRY. We've defined a wrapper called tryCatchSaga that automatically catches + dispatches a special error-action, which in turn can then trigger a notification.

Note that we're providing the saga as first and an options-object as second param, expanding the usage of this wrapper. I won't go into the details of this specific implementation, but as you can see we're updating a global progress-store and provide a subset of the AppStore - if desired by the caller.

/**
 *
 * @param saga
 * @param options
 */
export function tryCatchSaga<A, K extends keyof AppStore>(
  saga: (a: A, s?: AppStore[K]) => void,
  options?: { withProgress?: boolean; withStore?: K }
) {
  return function* (a: A) {
    let threadId: string | undefined = undefined;

    try {
      if (options?.withProgress) {
        threadId = uuidv4();
        yield put(updateProgressAction({ isLoading: true, threadId }));
      }
      const store = options?.withStore ? yield select((s: SHStore) => s[options!.withStore!]) : undefined;
      yield saga(a, store);
    } catch (error) {
      console.error(error);
      // TODO: Dispatch error action.
    } finally {
      if (options?.withProgress) {
        yield put(finishProgressThreadAction({ threadId }));
      }
    }
  };
}

And here you can see a simply example of the wrapper. The code for the saga itself stays clean and avoids repetition of at least the try/catch-blocks.

/*
 *
 * Watcher.
 *
 */

export default function* watcher() {
  yield takeEvery(UPDATE_USER_SETTINGS, tryCatchSaga(updateUserSettingsSaga, { withProgress: true }));
}

/*
 *
 * Sagas.
 *
 */
 
 /**
 *
 * @param a
 */
function* updateUserSettingsSaga(a: UpdateUserSettingsAction) {
  // ... implementation ...
}

And yeah, that's about it! Some little function can make a coder's life much easier. Redux itself can be heavy to work with regarding overhead code usage or type definitions, but what you get in turn is a stable and mature state-management.

- Tom