Why should I care about front-end architecture?

A few days ago, my Product Owner asked me: “How long would it take to implement an integration with a user behavior tracking system? We want to know how they interact with our application even when we apply A/B testing.” They were really afraid it would take a lot of time, since we would have to hook this tracking code up in many places. Also, to have only the end users data we want to set it up in production environment exclusively. While it may sound convoluted, it is not. Allow me to explain why.

I like working on big projects. The kind you spend months working on with your team (or in collaboration with many different teams). I like them because you really need to pay attention to details especially on the very beginning— you cannot leave something halfway done or “correctish”. If the technical debt is not being repaid immediately, it can accumulate ‘interest’, forcing you and your team to spend whole sprints without releasing new features just because of the refactoring. Code needs to be clear, easy to read and structured.

There is another reason why I like these kinds of challenges — architecture choices are extremely important and you need to consider many scenarios before you even create the structure of your files. You need to think how to make sure that every developer in your team will find the correct file without problems — how to name and group them. Today I would like to focus on two decisions — how we use React actions and sagas.

Current stack — React + Redux

At Bynder we have decided to go with the React and Redux libraries and I am very happy with that choice since it gives you really great control over the state of your application. Different teams are working on different modules, but we share the same architecture and approach when we create code. If something goes outside of component logic we dispatch an event which will be picked up by the reducer and it will change the state of the application. There is also another benefit to that decision — the visual layer and asynchronous logic are separated and they communicate only through actions. So if you want to create new component which copies already existing behavior or create a view with batch actions, the only thing you have to do is to dispatch the same action to handle the same logic. This factor led me to a simple solution for our tracking functionality.

A unified set of rules allows us to collaborate, help and review each others code easier. We can take advantage of the powerful composition model, given by React, reusing components created by different teams. Whenever you have a problem with your module someone else can help you out; it is a lot easier to read code which fits the same rules. I think that smooth collaboration is sufficient reason for applying a solid set of architectural rules but I want to show a real life example proving that strong architecture means more flexibility and also to tell you about a really important part of our stack — redux-saga.

What is redux-saga?

When someone asks that question, I tell them to imagine that they have one extra thread in their application. This very special thread has one responsibility — o take care of every side effect of the user’s actions. It is a great place to put all your calls to the server to fetch or save some data. Basic concept in this library is effect, which you may understand as a set of instructions which has to be fulfilled by the middleware, until that moment saga is paused.

Sagas use generator functions. If you are not familiar with them I highly recommend you to learn about them since it will make your life easier — especially when you are dealing with difficult processes and you run into problems like “callback hell”. This article doesn’t require extensive knowledge about that topic since it is mostly about the idea rather than specific implementation.

So let’s say that a user clicked the “Send” button to send a message — as a result of this action we see that the button changed state to disabled (which is handled by the component) but we also want to see the request to the server and after it succeeds display “Message sent” or “Error”. How we can do this with Redux-Saga?

import { call, put, takeEvery } from 'redux-saga/effects'
export function* sendMessage(action) {
try {
const data = yield call(Api.sendMessage, action.payload.message);
yield put({
type: 'MSG_SUCCEEDED',
data,
});
} catch (error) {
yield put({
type: 'MSG_FAILED',
error,
});
}
}
function* watchFetchData() {
yield takeEvery('MSG_SEND', fetchData);
}

As you see the code is really easy to read. So a saga is like someone who is watching your application and if it sees something interesting —it reacts. It may also affect your Redux application by creating new actions by using the put effect.

Let’s make the situation a bit more complicated by adding an extra button called “Cancel” which appears after the user clicks “Send”. Now we may say that we wait to see what will happen first — user clicks “Cancel” or we have a response from the server that the message was delivered. It is also really easy to make it happen with sagas by using therace effect:

import { call, put, takeEvery, race } from 'redux-saga/effects'
export function* sendMessage(action) {
try {
const { data } = yield race({
data: call(Api.sendMessage, action.payload.message),
cancel: take('MSG_CANCEL'),
});
yield put({
type: 'MSG_SUCCEEDED',
data,
});
} catch (error) {
yield put({
type: 'MSG_FAILED',
error,
});
}
}
function* watchFetchData() {
yield takeEvery('MSG_SEND', fetchData);
}

So race starts listening for two events: confirmation from send request or MSG_CANCEL action. You may find many cool examples on the project website.

Your tracking saga

Back to the original problem of a tracking system — imagine that you have a library for logging user’s actions. It has two methods: addLog() to a create log and setUserData() which sets experimentID in order to track results of A/B tests. You also need to listen for some events such as: login, logout, send message and download file. The first thing I did was creating some translations from codes to human readable information.

const LOGS = {
LOGIN: {
SUCCESS: 'Successfully logged in',
ERROR: 'Error in logging in',
},
LOGOUT: 'Logs out',
MESSAGE: {
SEND: 'Sends message',
},
FILE: {
DOWNLOAD: 'Downloads file',
},
};

Now we are creating the saga:

import loggingAPI from '...';
import { takeEvery } from 'redux-saga';
import type { Action } from 'redux';
const API = loggingAPI.getInstance();
const setContext = (action: Action) => {
API.setUserData({
experimentID: action.payload.experimentID,
});
};
const logEvent = (event: string) => {
API.addLog(event);
};
function* Logger(): Generator<*, *, *> {
yield takeEvery(SET_CONTEXT, setContext);
    yield takeEvery(LOGIN_ERROR, logEvent, LOGS.LOGIN.ERROR);
yield takeEvery(LOGIN_SUCCESS, logEvent, LOGS.LOGIN.SUCCESS);
yield takeEvery(LOGOUT, logEvent, LOGS.LOGOUT);
    yield takeEvery(MSG_SUCCEEDED, logEvent, LOGS.MESSAGE.SEND);

yield takeEvery(FILE_DOWNLOAD, logEvent, LOGS.FILE.DOWNLOAD);
}
export default Logger;

The last step is applying it to your store only if it’s needed. We do it during store initialization using a environment variable that tells us if it is a production or development environment.

In fact I don’t have to know how sending messages or downloading files is implemented. I don’t have to read those functions — I just need to know which action is triggered. If I want to change something in the logging logic — like introduce some paths to check which steps lead the user to perform some actions, I can do it without deep interaction with my colleague’s code.

Conclusion

  • It doesn’t matter which stack you are using during your frontend development — you need some architecture to follow. Otherwise after a few iterations your code will be really messy and difficult to read.
  • Your architecture should make it easy to inject new elements and functionalities.
  • If you are using Redux, sagas can improve your code quality a lot by creating the space for side effects which are difficult to put in React + Redux application lifecycle.
  • You may use generators as an observer to simplify working with Promises. You can pause it and wake it up whenever new data arrives asynchronously and to the generator it feels like it receives the data synchronously.
  • Sagas, as middleware, are easy to apply in your application.
  • Sagas are great to encapsulate and handle logic which works across different modules.