Refactoring listifi to use saga-query

· erock's devlog

Documenting journey to refactor listifi to use saga-query

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.

 1export const login = createAction("LOGIN");
 2function* onLoginLocal(body: LoginLocalParams) {
 3  const loaderName = Loaders.login;
 4  yield put(setLoaderStart({ id: loaderName }));
 5
 6  const resp: ApiFetchResponse<TokenResponse> = yield call(
 7    apiFetch,
 8    "/auth/login/local",
 9    {
10      auth: false,
11      method: "POST",
12      body: JSON.stringify(body),
13    },
14  );
15
16  if (!resp.ok) {
17    yield put(setLoaderError({ id: loaderName, message: resp.data.message }));
18    return;
19  }
20
21  const clientToken = resp.data.token;
22  yield call(postLogin, clientToken, loaderName);
23}
24
25function* watchLoginLocal() {
26  yield takeEvery(`${login}`, onLoginLocal);
27}
28
29export 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:

 1function* authBasic(ctx: ApiCtx<{ token: string }>, next: Next) {
 2  ctx.request = {
 3    body: JSON.stringify(ctx.payload),
 4  };
 5  yield next();
 6  yield call(postLogin, ctx);
 7}
 8
 9export const loginGoogle = api.post<AuthGoogle>(
10  "/auth/login/google",
11  authBasic,
12);
13export const login = api.post<LoginLocalParams>("/auth/login/local", authBasic);
14export 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.

 1// I'm going to cut out the action creation and saga watch logic just to make
 2// it easier to see the main differences.
 3
 4function* onFetchComments({ itemId, listId }: FetchComments) {
 5  const loaderName = Loaders.fetchComments;
 6  yield put(setLoaderStart({ id: loaderName }));
 7  const res: ApiFetchResponse<FetchListCommentsResponse> = yield call(
 8    apiFetch,
 9    `/lists/${listId}/items/${itemId}/comments`,
10  );
11
12  if (!res.ok) {
13    yield put(setLoaderError({ id: loaderName, message: res.data.message }));
14    return;
15  }
16
17  const comments = processComments(res.data.comments);
18  const users = processUsers(res.data.users);
19
20  yield batch([
21    setLoaderSuccess({ id: loaderName }),
22    addComments(comments),
23    addUsers(users),
24  ]);
25}
26
27function* onFetchListComments({ listId }: { listId: string }) {
28  const loaderName = Loaders.fetchListComments;
29  yield put(setLoaderStart({ id: loaderName }));
30  const res: ApiFetchResponse<FetchListCommentsResponse> = yield call(
31    apiFetch,
32    `/comments/${listId}`,
33  );
34
35  if (!res.ok) {
36    yield put(setLoaderError({ id: loaderName, message: res.data.message }));
37    return;
38  }
39
40  const comments = processComments(res.data.comments);
41  const users = processUsers(res.data.users);
42
43  yield batch([
44    setLoaderSuccess({ id: loaderName }),
45    addComments(comments),
46    addUsers(users),
47  ]);
48}

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.

 1function* basicComments(ctx: ApiCtx<FetchListCommentsResponse>, next: Next) {
 2  yield next();
 3  if (!ctx.response.ok) return;
 4  const { data } = ctx.response;
 5  const comments = processComments(data.comments);
 6  const users = processUsers(data.users);
 7  ctx.actions.push(addComments(comments), addUsers(users));
 8}
 9
10export const fetchComments = api.get<FetchComments>(
11  "/lists/:listId/items/:itemId/comments",
12  basicComments,
13);
14
15export const fetchListComments = api.get<{ listId: string }>(
16  "/comments/:listId",
17  basicComments,
18);

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.


I have no idea what I'm doing. Subscribe to my rss feed to read more posts.