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

The Fediverse can be pretty toxic

Mastodon, inspired by GNU social, together with Pleroma, form the most popular components of what we know as the “Fediverse” today. All of them are, in essence, federated, free software Twitter clones, interoperable with each other via the ActivityPub protoc…

via Drew DeVault's blog July 9, 2022

Spearphishing: it can happen to you too

Image generated by MidJourney -- The Fool in a woodcut tarot card style For some reason, LinkedIn has become the de-facto social network for professionals. It is viewed as a powerful networking and marketing site that lets professionals communicate, find new…

via Xe's Blog July 9, 2022

SourceHut is committed to making IRC better

Internet Relay Chat (IRC) is a wonderful protocol with a 34-year history of helping free software, there at every step alongside the rise of the internet. Many real-time chat empires have risen and fallen during its tenure, some of them leaving behind lesson…

via Blogs on Sourcehut July 6, 2022