Redux actions guidelines - OpenWebslides/openwebslides-frontend GitHub Wiki

Best practices & naming conventions

The purpose of this naming convention for actions is that it should be obvious based on the name of the action where it can be used and how it will be processed.

In general, redux actions in this app can be divided in two types:

Actions processed by a reducer

These directly change the state object and should not have side effects. Generally, an action of a certain type should only be processed by a single reducer. (Note that even though it is entirely possible to have a single action processed by multiple reducers in order to affect multiple parts of the state, this makes the code less easy to read / debug and it should be avoided in favor of using task sagas, described below.)

For example, todoList.actions.addItemToState(text, checked);, which would add a todo list item with the given text and checked status to the state. It should not affect other parts of the state or cause side effects that are not immediately clear from the action name, in order to maximize reusability of the action.

Naming convention for reducer actions

Reducer action should be named after the effect they have on the state. For example, a reducer action that adds a todo item to the state should be named todoList.actions.addItemToState(), with action constant ADD_ITEM_TO_STATE. Because the purpose of a reducer action is to change the state in some way, their names should always end in State (as seen in the examples below). The effect of a reducer action should be immediately obvious from its name, so that developers can easily reuse it without having to read the reducer code.

In most modules, reducer actions will be named pretty similar to each other. Some guidelines for frequently used action names:

  • addToState(prop1, prop2, ...):
    Add something to the state. It should take the props of whatever object needs to be added as separate parameters (as opposed to taking the entire object). That way, it is clear to developers which props are required and which are optional, and it enables the IDE's code completion feature to be used.
  • editInState(id, prop1, prop2, ...):
    Edit an object that is already present in the state. Its first parameter should be the id of whatever objects needs to be edited. Similar to the addToState() action, it should take the edited props as separate parameters. As a guideline, if a parameter is undefined, its associated prop should not be changed. (Note that we cannot use NULL for this, since a prop might be nullable and NULL would be a valid value to update this prop with.)
  • removeFromState(id)
    Remove a part of the state. Its only parameter should be the id of whatever object needs to be removed.
  • setInState(object)
    Takes an entire object and sets it in the state. Should mainly be used for restoring state from localStorage / an API, not for adding entirely new objects, since that way we would lose out on parameter validation / default prop values set by an addToState() action.

Depending on how the module is structured, its state might consist entirely out of an id -> object mapping, or it could have sub items. Reducer actions should be named accordingly. For example, a todo list could be structured in the following ways:

  • Module todoItems:
    State consists of a byId object. Actions should be named as described above.
  • Module todoList:
    State constists of an itemsById object and potentially other things as well. Reducer action names should make it clear what part of the state they are affecting. I.e. addItemToState(), editItemInState(), removeItemFromState(), setItemInState(). This leaves room for adding / editing / removing different types of things in the future.

Similar naming should be used when an action affects more than one object. For example:

  • removeMultipleFromState(ids[]) / removeMultipleItemsFromState(ids[])
  • removeAllFromState() / removeAllItemsFromState()
  • setMultipleInState(objects[]) / setMultipleItemsInState(objects[])

Reducer actions that deal with a single prop rather than objects should usually be named set[something]InState(), toggle[someting]InState(), etc. Examples:

  • setSortingOrderInState(order)
  • toggleVisiblityFilterInState()
  • setApiRequestStatusInState(method, status)
  • etc.

Actions processed by a saga

These either have side effects or involve asynchronous code. They should never change the state directly; instead, they should put() reducer actions which then change the state. As with reducer actions, a saga action of a certain type should only be processed by a single saga.

Because sagas can be used for two purposes (slide effects / asynchronous code), saga actions can again be divided into two types:

API saga actions

As the name implies, API saga actions trigger some form of communication with the backend API. An API saga should only be concerned with making API calls and processing the response. It should not cause further side effects, in order to maximize reusability. If side effects are necessary, we should first dispatch a task saga action, which would put() an API saga action and then handles further side effects.

For example, the saga that processes a todoList.actions.apiGetAllItems() action should also process the response to its API call by doing put(setMultipleItemsInState()) in order to persist the result of the API call in the state. It should not do things such as removing existing todo list items. In case such a side effect is necessary, a task saga action should be used instead (see below). For example, todoList.actions.replaceCurrentItemsWithApiItems(), which would first put(removeAllItemsFromState()) in order to clear the todo list, then put(apiGetAllItems()) in order to fetch the new ones and set them in the state. This way we can maximize the reusability of API sagas. (Say we did not do this and instead let the API saga take care of clearing the existing items, then if at a later stage we need to fetch items without clearing the list, then we would need to refactor (potentially large) parts of the code.)

(Note: for the same reasons, an apiDeleteItem() action should not perform put(removeItemFromState(id)) itself, since the removal is the reason for the API call, not a response to it, and thus should not be processed in the API saga.)

Naming convention for API saga actions

API saga actions should always start with api, followed by the HTTP method (GET/POST/PUT/PATCH/DELETE) they will be calling. Variations for items / multiple / all should be similar to those of reducer actions. For example:

  • apiGet(id) / apiGetItem(id) / apiGetMultiple(ids[]) / apiGetMultipleItems(ids[]) / apiGetAll() / apiGetAllItems()
  • apiPost(object) / apiPostItem(object) / apiPostMultiple(objects[]) / apiPostMultipleItems(objects[])
  • etc.

Because API actions are asynchronous, it might be useful to keep track of the current status of the API request and update the UI accordingly (for example, by displaying a spinner). This should be coupled to a specific part of the module state, i.e. modules.todoList.apiRequestStatus.get = PENDING. API sagas can update their status by dispatching todoList.actions.setApiRequestStatusInState(method, status). [TODO: expand on this later]

Task saga actions

Task saga actions trigger sagas that cause side effects. Task sagas are the 'upper layer' of action processing logic; most of the actions that are called directly from the user interface will be task saga actions. For example, the saga that processes a todoList.actions.removeItem(id) action would probably first put(apiDeleteItem(id)) and then put(removeItemFromState(id)).

Actions called from the UI should be limited to task saga actions as much as possible, even if there aren't currently any side effects. Reducer and API saga actions should mainly be called from inside task saga actions. This will make it easier to add side effects later on, because if everything already flows through the task saga, the task saga will be the only place that needs to be updated.

For example, say that the app is under development and there is no API connection yet. If the actions that are dispatched from the UI are reducer actions (e.g. todoList.actions.addItemToState()), and then a backend is added and we want to communicate all todo list changes to it, we would have to change all todoList.actions.addItemToState() actions dispatched from the UI to todoList.actions.addItem() (a task saga action which both updates the state and posts them to the API). It would have been much more future-proof to use todoList.actions.addItem() everywhere from the start, even if all it does in the beginning is pass its parameters to todoList.actions.addItemToState() without causing further side effects. That way, we can easily add the API call (and other side effects) to this saga when it becomes necessary.

Conclusion: by keeping reducers / API sagas free from side effects, we concentrate the code flow / business logic in the task sagas in an easy-to-read form. If we later want to review how a certain feature works and/or change it, the task saga is the only place where we need to look.

Naming convention for task saga actions

Task saga actions should describe their effect as accurately as possible, and should not start with api or end with State in order to avoid confusion with the other types of actions. In case their effect is a plain add/edit/remove, their naming should be similar to the reducer actions (as opposed to API saga actions, which should be named after HTTP methods).

Examples:

  • add() / addItem()
  • edit() / editItem()
  • remove() / removeItem()
  • check() / checkItem()
  • move() / moveItem()
  • replaceCurrentItemsWithApiItems()
  • etc.

Some notes on responsibilities

If we dispatch an action, different parts of the code will each have their own responsibilities for validation. A summary:

The actionType (actionTypes.js)

Responsibilities

Describes the shape of the action object. These should only be directly used in the action creators. Outside of these, we should never directly dispatch action objects; instead, we should call the associated action creator and dispatch its return value.

Naming

Action type constants, like all constants, should be named in all caps and snake_case. Example: ADD_ITEM. The string value contained in the constant should be namespaced for easier debugging / conflict prevention: todoList/ADD_ITEM_TO_STATE. The flow type associated with the action should be named the same as its type constant, but in PascalCase (like all flow types) and ending in Action. Example: AddItemToStateAction.

The action creator (actions.js)

Responsibilities

Describes an interface for creating its associated action object. Note that the parameters passed to the action creator need not be the same as the action.payload props; action creators can set default values, for example, or convert certain prop values to others (empty string to NULL, etc.). They can throw an error if a certain combination of parameters indicate a developer error (for example, attempting to create an edit action where all props are undefined). In general, action creators should standardize the action props as much as possible (for example by trimming strings or converting empty values to NULL) in order to keep the reducer / saga code simple.

Naming

Action creator names should map directly the type constants of the action object they return. Like all (non-React-component) functions, action creator should use camelCase. Example: addItemToState().

The reducer / saga

Responsibilities

Should validate the action payload props and throw an error if an invalid action is attempted (for example, if an invalid id is used, or if a non-nullable prop is set to NULL). It is the responsibility of the reducer to prevent the state from going invalid.

Naming

Reducers and sagas are functions, so they use camelCase. Their exact naming doesn't matter as much, as long as it is obvious which function does what and as long as the rootreducer and rootsaga for a given module are exported from the module index file as reducer and saga, respectively.