Frontend component intercommunication

Decision

Component intercommunication in the frontend will be handled via Redux following Redux's opinionated coding style using Redux toolkit.

  • Organize state structure based on data types, not components. read more
  • Put as much logic as possible in reducers. Reducers should almost be read as a state machine reacting to incoming state and actions and follows the event-driven approach. read more
  • Model actions as events not setters. read more
  • Subscribe your components to the store by defining a wrapper component that will inject the corresponding states extracted from the useSelector hook and actions mapped to the dispatch from the useDispatch hook. read more

Problems

Without an opinionated way of handling component intercommunication, components in different level of hierarchy end up "silo-ing" different types of information/actions that might be interesting for other components which leads to all kinds of coding style to solve these issues and finally resulting in a system that is difficult to understand and test.

Context

  • Our current system uses a combination of Reacts context and reducer library to share state/actions as well as a classic Redux store.
  • Custom hooks are used to abstract orchestrating logic and at the same time manage state that is interesting to several components.
  • There are scenarios where the same hook is rendered at the same time (e.g. several components are using the same hook and these components happen to be rendered at the same time) leading to vague state handling and repeated API/service calls.

Implementation Design

Terminology

  1. Redux store: a centralized entity that houses all information (state) that is interesting to more than one component. Logic of how states are mutated and the actual mutating process is handled by the store.
  2. Redux state: serializable data structure that represents the system's current state. This is housed inside the Redux store.
  3. Redux slice: a logical grouping of states that together make up the entirety of the store (slices are subsets of the store).
  4. Redux action: object representing an event which components use to interact with the store's state. Components dispatch actions in order to "request" a state mutation. Typically, includes a unique type field and a payload field.
  5. Redux thunk action: function that have access to the Redux store's dispatch and getState methods. These functions are able to "orchestrate" synchronous and asynchronous code.

Model Redux Store as the Application's Global State

Use the redux store as the centralized state manager that is accessible to any component in your component tree. Because the redux store is modeled to become the application's global state, it serves as a source of truth for all components to refer to and thus facilitating intercommunication across all levels of the tree.

NOTE: The global state should be treated as a "data entity" in that the slices should represent meaningful data-bound logic that dictates how your application/business logic is run. Thus, data that is strictly interesting to one single component should not be modeled into the store.

Writing Application Shared State Logic Guidelines

Organize State Structure Based on Data Types, Not Components

Extracted from Redux's coding style guide. source

Root state slices should be defined and named based on the major data types or areas of functionality in your application, not based on which specific components you have in your UI. This is because there is not a strict 1:1 correlation between data in the Redux store and components in the UI, and many components may need to access the same data. Think of the state tree as a sort of global database that any part of the app can access to read just the pieces of state needed in that component.

For example, a blogging app might need to track who is logged in, information on authors and posts, and perhaps some info on what screen is active. A good state structure might look like {auth, posts, users, ui}. A bad structure would be something like {loginScreen, usersList, postsList}.

Model Actions as Events, Not Setters

Extracted from Redux's coding style guide. source

We recommend trying to treat actions more as "describing events that occurred", rather than "setters". Treating actions as "events" generally leads to more meaningful action names, fewer total actions being dispatched, and a more meaningful action log history. Writing "setters" often results in too many individual action types, too many dispatches, and an action log that is less meaningful.

Put as Much Logic as Possible in Reducers

Extracted from Redux's coding style guide. source

Wherever possible, try to put as much of the logic for calculating a new state into the appropriate reducer, rather than in the code that prepares and dispatches the action (like a click handler). This helps ensure that more of the actual app logic is easily testable, enables more effective use of time-travel debugging, and helps avoid common mistakes that can lead to mutations and bugs.

  • Reducers are always easy to test, because they are pure functions - you just call const result = reducer(testState, action), and assert that the result is what you expected. So, the more logic you can put in a reducer, the more logic you have that is easily testable.
  • Redux state updates must always follow the rules of immutable updates. Most Redux users realize they have to follow the rules inside a reducer, but it's not obvious that you also have to do this if the new state is calculated outside the reducer. This can easily lead to mistakes like accidental mutations, or even reading a value from the Redux store and passing it right back inside an action. Doing all the state calculations in a reducer avoids those mistakes.
  • Time-travel debugging works by letting you "undo" a dispatched action, then either do something different or "redo" the action. In addition, hot-reloading of reducers normally involves re-running the new reducer with the existing actions. If you have a correct action but a buggy reducer, you can edit the reducer to fix the bug, hot-reload it, and you should get the correct state right away. If the action itself was wrong, you'd have to re-run the steps that led to that action being dispatched. So, it's easier to debug if more logic is in the reducer.
  • Finally, putting logic in reducers means you know where to look for the update logic, instead of having it scattered in random other parts of the application code.

When defining a reducer it is useful to keep in mind an "event-driven" design and to imagine the reducer as an entity that dictates how its inner "readonly" fields (states) mutate by attaching "listeners" to corresponding actions/thunks. (i.e. "when X action/thunk happens, then Y states are mutated accordingly")

Use Thunk Actions to Orchestrate Async Logic

Because thunk actions are functions that receive as parameters the store's dispatch and getState methods, they are prime candidates to:

  1. make async API calls to any external service, and report back the results with a dispatch call.
  2. access the current state using getState and orchestrate any outstanding state mutation logic that did not fit inside the reducer.

Note: although thunk actions can dispatch other thunk actions, this pattern should be minimized. In general, thunks needing to dispatch another thunk represents an opportunity to refactor logic.

Example:

// Thunk Action Definition
const payloadCreator = async (param, { dispatch, getState, extra }) => {
  try {
    // parameters passed by consumer of this thunk action
    const { someId } = param;

    // injected services available via the "extra" parameter
    const { serviceA, serviceB } = extra.services;

    // orchestrate async logic
    const { outputA } = await serviceA.findById(someId);
    const { outputB } = await serviceB.consume(outputA);

    return {
      result: outputB,
    };
  } catch (e) {
    // error handling logic that does not fit inside of reducers
    const { errorHandler } = extra.services;
    errorHandler.logError({
      error: e,
      context: {
        ...getState("example"),
      },
    });
    errorHandler.propagateError(e);
  }
};

export const exampleEvent = createAsyncThunk(
  // type prefix must be different from existing redux actions.
  // Upon collision, action defined in a reducer will take precedence over this one
  "example/exampleEvent",
  payloadCreator,
);

// Reducer Definition
const exampleSlice = createSlice({
  name: 'example',
  initialState,
  // Notice how the reducer can be read as a "state machine", dictating how states
  // reacts to incoming actions and computing the corresponding final state
  reducers: {
    internalEventA: (state, action) => {
      const newAFields = {
          // business logic goes here
          ...action.payload
      }
      // redux toolkit makes use of the "immer" library to convert mutating code to immutable code
      state.eventAFields = newAFields
    },
    internalEventB: (state, action) => {
      // alternatively, can continue to write new states in the immutable way
      return {
        ...state,
        eventBFields: action.payload
      }
    }
  },
  // attach "listeners" for actions created via createAsyncRedux and define
  // new states according to the given current state and incoming action
  extraReducers: (builder) => {
    builder.addCase(exampleEvent.pending, (state, action) => {
      state.exampleEventStatus = "ongoing";
    });
    builder.addCase(exampleEvent.fulfilled, (state, action) => {
      const { result } = action.payload;
      state.exampleEventStatus = "success";
      state.exampleEvent.result = result;
    });
    builder.addCase(exampleEvent.rejected, (state, action) => {
      state.exampleEventStatus = "failure";
    });
  },
})

Note: we had an extensive discussion about streamlining Thunk usage into React Query / RTK Query instead and were not able to reach consensus - thus we decided it should be addressed in future ADR.

Subscribing Components to the Store

While modern Redux encourages to use the new hooks API (useSelector and useDispatch), these hooks' comes with one major disadvantage:

  • Components gain exposure to useSelector, useDispatch, and any other action/thunk directly via the imports, making unit tests difficult as the only possible way to mock these without writing a "test" context provider is to directly mock the imports. Furthermore, in cases where the useSelector is called more than once (which Redux recommends to improve performance), then the mocking step becomes even more cumbersome (need to call mock once, twice, etc...)

One way to circumvent this downside, is to define a wrapper component that will make the calls to useDispatch and useSelector and inject the interesting states and/or actions to the inner component. Naturally, testing becomes trivial since all we need to do is inject any mocked state/function and expect the resulting UI changes.

NOTE: For clarity, the wrapped component should be named as "InternalXYZ" and this internal component should only be imported for use in test files. For real usage, the wrapping XYZ component should be used.

Example:

// example of how to handle typing dispatch-wrapped actions
function mapDispatchAsyncThunk<ActionArgs>(
  dispatch: ReturnType<typeof useDispatch>,
  thunk: AsyncThunk<any, ActionArgs, AsyncThunkConfig>,
) {
  return (args: ActionArgs) => dispatch(thunk(args));
}
type MappedDispatchAsyncThunk<T> = ReturnType<
  typeof mapDispatchAsyncThunk<T>
>;

interface Props {
  id: IFooReducer["id"];
  someName: IFooReducer["nested-field"]["name"];
  login: MappedDispatchAsyncThunk<{ args: FooArg }>
}

// real component to be used
export const MyComponent: React.FC = () => {
    const dispatch = useDispatch();
    const { id, someName } = useSelector(({ fooSlice }: IStore) => ({
        id: fooSlice.id,
        someName: fooSlice.nested.name,
    }));

    return (
      <InternalMyComponent
        id={id}
        someName={someName}
        login={mapDispatchAsyncThunk(dispatch, loginThunk)}
      />
    )
}

// internal component used for tests
export const InternalMyComponent = ({
  id,
  someName,
  login
}: Props) => {
  // ...component body
  function onSubmit() {
    login(username, password)
  }
}

// in test file
import { InternalMyComponent } from "./MyComponent"

const mockId = "foo-id"
const mockSomeName = "foo-name"
const mockLogin = jest.fn()

test("simply inject all your mocked dependencies to your component", () => {
  render(
    <InternalMyComponent
      id={mockId}
      someName={mockSomeName}
      login={mockLogin}
    />
  )

  expect(mockLogin).toHaveBeenCalledWith({...})
})

Alternatives

React Context + useReducer vs Redux

Context is Reacts dependency injection mechanism for solving the prop-drilling issue and the useReducer hook is a supercharged useState that allows you to define more fine-grained state mutation following the Flux design matter (which Redux is also based on). Together they are enough to create an in-house state management system. Define your reducer with useReducer, and expose it via Context.

Although it is possible to create an in-house state management system, it does not mean that we should:

  1. Much of the boilerplate needed to write a sound state management system can be avoided by using redux-toolkit.
  2. Let's say we would like to add side effects to our state management system, now we would need to implement this ourselves again whereas Redux has functionalities like middleware with well-known libraries such as thunk, redux-observable, etc...
  3. Redux comes with the DevTool debugger.

In a nutshell, yes we can use Context + useReducer, but it comes with the cost of implementing and maintaining it ourselves.

NOTE: Context + useReducer can still be valuable and even the right tool to use given the circumstances. For instance, if your component has complicated data management logic that is not interesting for other components, defining states with useReducer is way better than using multiple useState calls.

read here for a more detailed comparison

Imperative instead of event-driven

Rather than following the recommended event-driven model, we can follow a more imperative coding style where redux actions take charge of orchestrating everything, from dispatching all corresponding state mutations to handling all async communication with external services and error handling.

Example:

// Thunk Action Definition
export const exampleEvent = (param: { someId: string }) => async (dispatch, getState, extraArguments) => {
  dispatch(setExampleEventStatus("ongoing"))
  try {
    // parameters passed by consumer of this thunk action
    const { someId } = param;

    // injected services available via the "extra" parameter
    const { serviceA, serviceB } = extraArguments.services;

    // orchestrate async logic
    const { outputA } = await serviceA.findById(someId);
    const { outputB } = await serviceB.consume(outputA);

    // dispatch state mutation needed in other slices here...

    dispatch(setExampleEventStatus("success"))
    return {
      result: outputB,
    };
  } catch (e) {
    dispatch(setExampleEventStatus("failure"))
    // error handling logic that does not fit inside of reducers
    const { errorHandler } = extra.services;
    errorHandler.logError({
      error: e,
      context: {
        ...getState("example"),
      },
    });
    errorHandler.propagateError(e);
  }
}

// Reducer Definition
const exampleSlice = createSlice({
  name: 'example',
  initialState,
  // Reducers are slimmer and simply takes the payload input and mutates the state accordingly
  // all logic of how payload values are computed are handled by the dispatcher
  reducers: {
    setExampleEventStatus: (state, action) => {
        state.setExampleEventStatus = action.payload
    },
    internalEventA: (state, action) => {
      // redux toolkit makes use of the "immer" library to convert mutating code to immutable code
      state.eventAFields = action.payload
    },
    internalEventB: (state, action) => {
      // alternatively, can continue to write new states in the immutable way
      return {
        ...state,
        eventBFields: action.payload
      }
    }
  },
  // no more action "listeners" are defined.
})

Event-Driven VS Imperative at a Glance

| | Event-Driven | Imperative | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | Logic concentration | In reducers | In thunk actions | | Reducer's role | Reducers reads as "state machine" reacting to current state and the incoming action payload | Reducers are plain functions that assigns states to the given payload | | Thunks role | Thunk actions are simple and only handle async logic + any other logic that does not belong to the reducer (e.g. error handling, logging, etc...) | Thunk actions houses all logic | | Separation of concerns | Clear separation of concerns: each reducer defines how state reacts to incoming actions | Tightly coupled thunk actions | | Ease of use | Requires careful design of redux slices | Slices and thunk actions are straightforward to write |

Consequences

How do we implement this change?

Start by evaluating if your team's codebase has the same/similar pain point described in this ADR. Next, evaluate if the codebase is large enough to warrant the learning curve of Redux. As a rule of thumb, if your pain point is strictly related to prop drilling, then Redux is most likely an overkill. Otherwise, if your codebase is large or growing fast and your team would like to have an opinionated way of handling intercommunication with an added benefit of separation of concerns, then Redux is a good solution.

Under the hood, Redux Toolkit works exactly the same as classic Redux, so it provides flexibility to migrate "slices" one at a time. The process of implementing this change will vary from team to team depending on their current priorities, pain-points and situation, but the following steps should be taken into consideration when starting the implementation process:

  • Read through the recommended coding style guide detailed in the section of Implementation Design and look for opportunities to align the current code to the recommended ones. Make sure to follow the recommended guidelines for new lines of code.
  • If classic Redux is already in use but Redux Toolkit is not, start by re-defining how your store is configured using the recommended configureStore function together with combineReducer. This will allow your team to migrate "slices" of the current store one at a time since combineReducer can accept both classically-defined reducers and reducers defined using createSlice.
  • Once your state machine has been modeled into a reducer, refactor-out related logic that exists inside of your hooks/ components. The refactored container/component should mostly behave as "subscribed" to certain states, and "fires" redux actions. Details of how these actions work should no longer be found inside of the refactored hooks/component.

What could go wrong?

It is tempting to place everything inside of the store and simply subscribe to it. Not only does this deteriorates performance, it can lead to awkward lines of code or unnecessarily complicated solutions.

What do we do if something goes wrong?

In most of the cases, problems related to redux arise from a badly modeled reducer or listeners. Start by debugging with the provided DevTool, and then analyze the correctness of the data flow. This will usually be enough to point to to the solution.

As a last resource, you can always opt-out of redux and rely on simpler constructs like hooks.

What is still unclear?

Communication with Backend/external services is still debated (Thunk, RTK Query, React Query, etc..).

Related