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:
- We start the loader
 - We make the request
 - We check if the request was successful
 - We extract and trasform the data
 - We save the data to redux
 - We stop the loader
 
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.