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.