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 Self Provisioning Runtime

Improvements in DX in both programming languages and cloud infrastructure will eventually converge in a single paradigm, where you truly "just write business logic" and the platform mostly figures out the rest.

via Swyx.io RSS Feed August 30, 2021

Status update, August 2021

Greetings! It’s shaping up to be a beautiful day here in Amsterdam, and I have found the city much to my liking so far. If you’re in Amsterdam and want to grab a beer sometime, send me an email! I’ve been making a lot of new friends here. Meanwhile, I’ve als…

via Drew DeVault's blog August 15, 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