Refactoring listifi to use saga-query

June 20, 2021 on Eric Bower's blog

Over the weekend I decided to refactor listifi.app to use my new library saga-query.

The results of that work culminated in a PR where I was able to remove roughly 300 lines of business logic from the listifi codebase. As you’ll see, I was able to accomplish this by abstracting typical lifecycle processes like setting up loaders – which were previously manually written – to use middleware through saga-query. The conversion process was pretty painless.

I’ll extract a couple of examples demonstrating how we can dramatically remove business logic by using saga-query.

Example: Authentication logic

The previous API interaction was built with redux-cofx which for the purposes of this blog article can be hot swapped for redux-saga.

Here are the before and after files for my authentication logic:

The logic is these files are identical. A cursory glance can see that I was able to reduce the amount of code by 50%. Let’s zoom in to see what changed by looking at a single API request.

export const login = createAction('LOGIN');
function* onLoginLocal(body: LoginLocalParams) {
  const loaderName = Loaders.login;
  yield put(setLoaderStart({ id: loaderName }));

  const resp: ApiFetchResponse<TokenResponse> = yield call(
    apiFetch,
    '/auth/login/local',
    {
      auth: false,
      method: 'POST',
      body: JSON.stringify(body),
    },
  );

  if (!resp.ok) {
    yield put(setLoaderError({ id: loaderName, message: resp.data.message }));
    return;
  }

  const clientToken = resp.data.token;
  yield call(postLogin, clientToken, loaderName);
}

function* watchLoginLocal() {
  yield takeEvery(`${login}`, onLoginLocal);
}

export const sagas = { watchLoginLocal };

This is what a common saga looks like in listifi:

This is a painfully redundent process and any attempt to abstract the functionality end up creating a large configuration object to accommodate all use-cases. I have two other functions that do virtually the exact same thing:

loginGoogle, and register.

Now let’s see what it looks like with saga-query:

function* authBasic(ctx: ApiCtx<{ token: string }>, next: Next) {
  ctx.request = {
    body: JSON.stringify(ctx.payload),
  };
  yield next();
  yield call(postLogin, ctx);
}

export const loginGoogle = api.post<AuthGoogle>(
  '/auth/login/google',
  authBasic,
);
export const login = api.post<LoginLocalParams>('/auth/login/local', authBasic);
export const register = api.post<RegisterParams>('/auth/register', authBasic);

Wow! I was able to completely abstract the request lifecycle logic into a single function and then have loginGoogle, login, and register use it. How is this possible? We’re able to inject lifecycle hooks into our function by using pre-built middleware: requestMonitor and requestParser which get registered once for all endpoints here.

Example: Comment logic

Here’s another example I came across when refactoring that was also a very pleasent developer experience. I have logic to fetch comments not only for the list but also for each list item in that list. The logic is very similar: fetch the data and extract the comments to save them to redux. I have two functions: onFetchComments and onFetchListComments.

// I'm going to cut out the action creation and saga watch logic just to make
// it easier to see the main differences.

function* onFetchComments({ itemId, listId }: FetchComments) {
  const loaderName = Loaders.fetchComments;
  yield put(setLoaderStart({ id: loaderName }));
  const res: ApiFetchResponse<FetchListCommentsResponse> = yield call(
    apiFetch,
    `/lists/${listId}/items/${itemId}/comments`,
  );

  if (!res.ok) {
    yield put(setLoaderError({ id: loaderName, message: res.data.message }));
    return;
  }

  const comments = processComments(res.data.comments);
  const users = processUsers(res.data.users);

  yield batch([
    setLoaderSuccess({ id: loaderName }),
    addComments(comments),
    addUsers(users),
  ]);
}

function* onFetchListComments({ listId }: { listId: string }) {
  const loaderName = Loaders.fetchListComments;
  yield put(setLoaderStart({ id: loaderName }));
  const res: ApiFetchResponse<FetchListCommentsResponse> = yield call(
    apiFetch,
    `/comments/${listId}`,
  );

  if (!res.ok) {
    yield put(setLoaderError({ id: loaderName, message: res.data.message }));
    return;
  }

  const comments = processComments(res.data.comments);
  const users = processUsers(res.data.users);

  yield batch([
    setLoaderSuccess({ id: loaderName }),
    addComments(comments),
    addUsers(users),
  ]);
}

You can see that I tried to abstract as much as I could previously, but because of subtle differences between the two functions, it didn’t seem worth it to take it much further. With saga-query it was clear how to improve these functions.

function* basicComments(ctx: ApiCtx<FetchListCommentsResponse>, next: Next) {
  yield next();
  if (!ctx.response.ok) return;
  const { data } = ctx.response;
  const comments = processComments(data.comments);
  const users = processUsers(data.users);
  ctx.actions.push(addComments(comments), addUsers(users));
}

export const fetchComments = api.get<FetchComments>(
  '/lists/:listId/items/:itemId/comments',
  basicComments,
);

export const fetchListComments = api.get<{ listId: string }>(
  '/comments/:listId',
  basicComments,
);

Once again, by using a middleware system with saga-query I was able to cut out a ton of repeated logic.

Conclusion

This trend of being able to leverage a middleware system to remove duplicated logic for every API interaction was common in this refactor which resulted in less code and a better developer experience.

Visit the saga-query repo to learn more about how the middleware system works.


Articles from blogs I read

Generated by openring

I will pay you cash to delete your npm module

npm’s culture presents a major problem for global software security. It’s grossly irresponsible to let dependency trees grow to thousands of dependencies, from vendors you may have never heard of and likely have not critically evaluated, to solve trivial tas…

via Drew DeVault's blog November 16, 2021

Community Heat, or Why You Should Get Good at Events

A piece of advice I heard about marketing and community that I've repeated to founders ever since - Get Good at Events.

via Swyx.io RSS Feed November 8, 2021

npm audit: Broken by Design

Found 99 vulnerabilities (84 moderately irrelevant, 15 highly irrelevant)

via Dan Abramov's Overreacted Blog RSS Feed July 7, 2021