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

Developers: Let distros do their job

I wrote a post some time ago titled Developers shouldn’t distribute their own software, and after a discussion on the sr.ht IRC channel today, the topic seems worthy of renewed mention. Let’s start with this: what exactly is a software distribution, anyway? I…

via Drew DeVault's blog September 27, 2021

Why do Webdevs keep trying to kill REST?

How I broke out of the tiresome REST vs GraphQL debate - by realizing that the real battle is between Smart Clients and Smart Servers!

via Swyx.io RSS Feed September 20, 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