DEV Community

harry
harry

Posted on

Using DDD with Recoil

Introduction

The following repository code is presented as an "Interactive Tutorial" on the official Recoil website.

https://github.com/SideGuide/recoil-example

I will try to rewrite this code using the design method I use in my published application. The target readers are those who have already read the Getting Started and Tutorial of Recoil. Therefore, I will not provide a basic explanation of Recoil in this article.

I designed my application with two things in mind.

  1. Ensure domain layer independence
  2. Concerns about rendering performance

My application has an ability to add and subtract values by holding down a button. So rendering time for a single change is less than 20ms, even in development mode.

When actually running in development mode, the screen is rendered as follows.

Performance tab in DevTools

Assumptions

  • Using TypeScript
  • Domain logic has no asynchronous processing, only pure processing

All of the artifacts in this article

https://github.com/harry0000/recoil-todo-app

original tag
Code copied from the original repository and minimally typed in TypeScript
refactored tag
Code that reflects the changes made in this article

💥 Problems with this Todo app

Let's try to run this Todo application with a new project and minimal typing in TypeScript. Then all components will be re-rendered just by changing the checkbox of an item. There is no point in using Recoil.

Behavior before refactoring

In addition, because it is simplified sample code, the domain logic is not cohesive and is not easy to test and maintain.

🛠 Let's refactor

1. Cohesive domain logic

Put together our domain logic in the domain directory.

The important point here is that Recoil expects immutable values to be stored, as evidenced by the fact that it freezes the values stored in development mode. Therefore, if you are building object-based domain code, implement it so that it returns a new object when the result of the logic execution changes, and if it is class-based, implement it as an immutable class.

The object-based implementation was used in the original Todo application and is probably already familiar to front-end engineers, so this time I will try a class-based implementation. I will keep the function names in the original code as much as possible, and aggregate the domain logic.

When testing these domain codes, you simply write test code similar to testing a class.

2. Implementing the Recoil state module

Now that the domain logic is implemented, all we need to do is store it in an atom, get the value with a selector, and we're done?

However, this is not the case, and the get of the selector is re-evaluated when the dependent atom is updated, which can cause unnecessary re-rendering. If the type of the value to get is of a type (e.g. object, array) whose equivalence cannot be determined by the strict equality operator (===), and where the cached value is not expected to be used by the selector, you should generally consider using selector for values that always change when the dependent atom is updated. However, it is a bit odd that the state layer always needs to know if the value has changed after the domain logic is executed. Also, as in the case of this Todo application, it seems to me that it is a rare case where all values change after a particular domain logic is executed.

My application uses objects like { value: number }, { milliValue: number }, and { microValue: number } in the domain logic, which cannot determine equivalence of values with === operators and in many cases I cannot expect the selector cache to be used.

Implementation policy

What is the policy for implementation?

  1. Prepare separate Recoil state values to store the values referenced by each component.
  2. Export only two types of elements from the state module.
    1. RecoilValueReadOnly to reference the state
    2. Callback functions to update the state
  3. When the callback is invoked, the domain logic is executed and each value is stored in the state prepared in point 1.

In one sentence, the states referenced by each component are prepared separately, and the state update logic is hidden in the state module.

The reason for doing it like point 1 is to use the state only where it is displayed, as in the following video thumbnail. This ensures that only components that reference values that have actually changed are re-rendered.

However, if you export these RecoilState as they are, useRecoilState() can be called on any component, and the state can be changed from anywhere and in any way, and the integrity of the state cannot be guaranteed. At least for now, Recoil does not have the ability to change other atoms in response to a change in a particular atom.

So, using TypeScript, cast RecoilState to the type RecoilValueReadOnly and export it. Now any component cannot call anything on this state except useRecoilValue().

My personal practice is to give a short, free name in high context to the states that are not exported, but to export states I use a name that can be understood by external modules, and I always add the suffix State.

  • Example:
const _foo = atomFamily<boolean, number>({
  key: 'somestate_foo',
  default: false
});

export const fooState: (id: number) => RecoilValueReadOnly<boolean> =
  _fooState(id);
Enter fullscreen mode Exit fullscreen mode

This time we need to prepare these states:

  • Text and completion status for each todo
  • Filter for todo list
  • List of filtered todo lists
  • Total number of todos
  • Total number of completed todos
  • Total number of incomplete todos
  • Percentage of completed todos
  • Text list of incomplete todos

Each state requires an initial value defined in the domain logic. The state is updated by a callback that is exported separately. Basically, it is someone (or something) outside the state module that executes the trigger that changes the state, so we provide the means to do so by exporting a callback.

Recoil provides a way to get/set the state with a callback. This callback is actually used via useRecoilCallback() in components and custom hooks, so it needs to be defined with a type like CallbackInterface => (...any[]) => any.

See also: https://recoiljs.org/docs/api-reference/core/useRecoilCallback/

In this case we would define a callback of the following type:

{
  addTodoItem: (cbi: CallbackInterface) => (text: string): void;

  editTodoItemText: (cbi: CallbackInterface) => (id: TodoItemId, text: string): void;

  toggleTodoItemCompletion: (cbi: CallbackInterface) => (id: TodoItemId): void;

  deleteTodoItem: (cbi: CallbackInterface) => (id: TodoItemId): void;

  updateFilter: (cbi: CallbackInterface) => (filter: TodoListFilter): void;
}
Enter fullscreen mode Exit fullscreen mode

It is possible to get the state via snapshot of CallbackInterface and update the state via set / reset.

  • Type definition of CallbackInterface:
type CallbackInterface = {
  snapshot: Snapshot,
  gotoSnapshot: Snapshot => void,
  set: <T>(RecoilState<T>, (T => T) | T) => void,
  reset: <T>(RecoilState<T>) => void,
  refresh: <T>(RecoilValue<T>) => void,
  transact_UNSTABLE: ((TransactionInterface) => void) => void,
};
Enter fullscreen mode Exit fullscreen mode
  • Example of getting a value using a snapshot:

Since it is assumed that there is no asynchronous processing in the domain logic, you can get the value synchronously from the state.

import { GetRecoilValue } from 'recoil';

const get: GetRecoilValue = (recoilVal) => snapshot.getLoadable(recoilVal).getValue();

const someValue = get(someState);
Enter fullscreen mode Exit fullscreen mode

Actual implementation code

There are two possible implementations of the state module: value/function-based and class-based.

value/function-based
Value and function are defined at the top level of the module
class-based
Defining a class and exporting its singleton instance

Since the original Todo application is value/function-based and there is nothing unusual about it, I want to implement it as class-based this time. In the case of class-based, since the recoil state is defined in the field of the class, only a single instance is exported since the definition of the state (key) will be duplicated if multiple instances are created.

  • src/state/recoil_state.ts
class TodoState {
  // ...
}

export const todoState = new TodoState();
Enter fullscreen mode Exit fullscreen mode

It would be more appropriate to rename the file to TodoState.ts, but I left it as is.

If you want to implement a value/function-based module, move each field of the class to the top level of the module and export the field that was public. Note that you cannot use # as a variable prefix, you must change it to _ or something similar.

As for the state that each component refers to, just cast the defined one with RecoilValueReadOnly and export it as described above.

For the todo list, detailed information about each todo is obtained from the end components that display it, so its type is defined as atom<ReadonlyArray<TodoItemId>() and the id is passed as props in the component. Then the todo text and completion state are held in atomFamily<string, TodoItemId>() and atomFamily<boolean, TodoItemId>() respectively.

The code for the definition section is omitted here, but one point should be mentioned: code completion may be easier if state is defined in meaningful block units.

  readonly #items = {
    text: atomFamily<string, TodoItemId>({ key: 'TodoState.#items.text', default: TodoItem.initialState.text}),
    completion: atomFamily<boolean, TodoItemId>({ key: 'TodoState.#items.completion', default: TodoItem.initialState.isComplete})
  };
Enter fullscreen mode Exit fullscreen mode

Now let's write the process of storing a value in state when each callback is invoked. We can simplify the implementation of all callbacks by preparing an atom that stores the domain object and creating a #updateState like this:

class TodoState {

  readonly #itemContainer = atom({
    key: 'TodoState.#itemContainer',
    default: TodoItemContainer.create()
  });

  // ...

  #updateState = ({ set, reset, snapshot }: CallbackInterface) => (updater: (state: TodoItemContainer) => TodoItemContainer): void => {
    // TODO: implement
  }

  // ...

  readonly addTodoItem = (cbi: CallbackInterface) => (text: string): void => {
    this.#updateState(cbi)(state => state.addItem(text));
  };

  readonly editTodoItemText = (cbi: CallbackInterface) => (id: TodoItemId, text: string): void => {
    this.#updateState(cbi)(state => state.editItemText(id, text));
  };

  readonly toggleTodoItemCompletion = (cbi: CallbackInterface) => (id: TodoItemId): void => {
    this.#updateState(cbi)(state => state.toggleItemCompletion(id));
  };

  readonly deleteTodoItem = (cbi: CallbackInterface) => (id: TodoItemId): void => {
    this.#updateState(cbi)(state => state.deleteItem(id));
  };

  readonly updateFilter = (cbi: CallbackInterface) => (filter: TodoListFilter): void => {
    this.#updateState(cbi)(state => state.setFilter(filter));
  };
}
Enter fullscreen mode Exit fullscreen mode

All that remains is to implement #updateState. Since we implemented the domain object in the immutable class, we can set the updated value without thinking about anything in particular. Also, if the type of the state can be determined to be equivalent with the === operator, it can be set as is, since update propagation and re-rendering will not be performed if it is the same as the previous value.

The problem is with arrays, objects, etc., where even if the values are equivalent, if the references are different, the comparison with the === operator will result in false and state update propagation and re-rendering will occur. So, use fast-deep-equal to compare with the previous value and set only if there is a change. Unless it is a very special case, the cost of a fast-deep-equal comparison should be less than the cost of a re-rendering without it.

In addition, when deletions may occur as a result of updates, as in this Todo application, it is necessary to reset the state by comparing it to the previous state and identifying the deleted items.

These points should be noted and implemented:

  #updateState = ({ set, reset, snapshot }: CallbackInterface) => (updater: (state: TodoItemContainer) => TodoItemContainer): void => {
    const get: GetRecoilValue = (recoilVal) => snapshot.getLoadable(recoilVal).getValue();

    const prevState = get(this.#itemContainer);
    const nextState = updater(prevState);
    set(this.#itemContainer, nextState);

    set(this.#filter, nextState.filter);

    const prevFilteredIds = get(this.#filteredItemIds);
    const nextFilteredIds = nextState.filteredItems.map(({ id }) => id);
    if (!deepEqual(prevFilteredIds, nextFilteredIds)) {
      set(this.#filteredItemIds, nextFilteredIds);
    }

    set(this.#numOfTotal, nextState.numOfTotal);
    set(this.#numOfCompleted, nextState.numOfCompleted);
    set(this.#numOfUncompleted, nextState.numOfUncompleted);
    set(this.#percentCompleted, nextState.percentCompleted);

    const prevTexts = get(this.#uncompletedTexts);
    const nextTexts = nextState.uncompletedItems.map(({ text }) => text);
    if (!deepEqual(prevTexts, nextTexts)) {
      set(this.#uncompletedTexts, nextTexts);
    }

    getDeletedItemIds(prevState, nextState).forEach(id => {
      reset(this.#items.text(id));
      reset(this.#items.completion(id));
    });
    nextState.items.forEach(({ id, text, isComplete }) => {
      set(this.#items.text(id), text);
      set(this.#items.completion(id), isComplete);
    });
  }
Enter fullscreen mode Exit fullscreen mode
  • 🚨 Another thing to note is that the value you can get from a snapshot during a callback call is always the same. That is, if you get a value via snapshot after set, it will not be updated to the value you just set, but will remain the same as it was before set.

3. Implementing Components

Finally, we are ready to use the Recoil state in components.

In this example code, I will call useRecoilValue() and useRecoilCallback() directly in the component, but you can create custom hooks if you want.

Referring to the state

If you want to refer to a state like the thumbnail in the official Recoil video, refer to it in the node (component) at the end of the DOM tree as much as possible to display it.

In this Todo application, the code in TodoListStats.tsx is the simplest example:

import React from 'react';
import { useRecoilValue } from 'recoil';

import { todoState } from '../state/recoil_state';

const {
  todoListTotalState,
  todoListTotalCompletedState,
  todoListTotalUncompletedState,
  todoListPercentCompletedState,
  todoListNotCompletedTextsState
} = todoState;

const Total = () => {
  const totalNum = useRecoilValue(todoListTotalState);

  return (<span>{totalNum}</span>);
};

const TotalCompleted = () => {
  const totalCompletedNum = useRecoilValue(todoListTotalCompletedState);

  return (<span>{totalCompletedNum}</span>);
};

const TotalUncompleted = () => {
  const totalUncompletedNum = useRecoilValue(todoListTotalUncompletedState);

  return (<span>{totalUncompletedNum}</span>);
};

const FormattedPercentCompleted = () => {
  const percentCompleted = useRecoilValue(todoListPercentCompletedState);
  const formattedPercentCompleted = Math.round(percentCompleted * 100);

  return (<span>{formattedPercentCompleted}</span>);
};

const UncompletedTexts = () => {
  const texts = useRecoilValue(todoListNotCompletedTextsState);

  return (<span>{texts.join(' ')}</span>);
};

const TodoListStats = () => {
  return (
    <ul>
      <li>Total items: <Total /></li>
      <li>Items completed: <TotalCompleted /></li>
      <li>Items not completed: <TotalUncompleted /></li>
      <li>Percent completed: <FormattedPercentCompleted /></li>
      <li>Text not completed: <UncompletedTexts /></li>
    </ul>
  );
};

export default TodoListStats;
Enter fullscreen mode Exit fullscreen mode

Update the state

Just get the callback with useRecoilCallback() and call it when you need it.

import React, { ChangeEventHandler, useState } from 'react';
import { useRecoilCallback } from 'recoil';

import { todoState } from '../state/recoil_state';

function TodoItemCreator() {
  const [inputValue, setInputValue] = useState('');
  const addTodoItem = useRecoilCallback(todoState.addTodoItem);

  const addItem = () => {
    addTodoItem(inputValue);
    setInputValue('');
  };

  const onChange: ChangeEventHandler<HTMLInputElement> = ({ target: { value } }) => {
    setInputValue(value);
  };

  return (
    <div>
      <input type="text" value={inputValue} onChange={onChange} />
      <button onClick={addItem}>Add</button>
    </div>
  );
}

export default TodoItemCreator;
Enter fullscreen mode Exit fullscreen mode

One last push

When I check the behavior after changing all the components, I notice that each row of the todo list is re-rendered when a todo is added, for example. It seems that the TodoItemList component in TodoList.tsx updates the filtered todo list when it renders. Currently the TodoItem component only gets TodoItemId in its props, so we can suppress the re-rendering by memoization.

const TodoItem: React.FC<{ itemId: TodoItemId }> = React.memo(({ itemId }) => {
  return (
    <div>
      <TodoItemTextField itemId={itemId} />
      <TodoItemCompletionCheckbox itemId={itemId} />
      <TodoItemDeleteButton itemId={itemId} />
    </div>
  );
});
Enter fullscreen mode Exit fullscreen mode

🎉 After refactoring

With all the changes made, let's see how it works. Unlike the original Todo application, only the components that reference the updated values should be re-rendered.

Behavior after refactoring

Conclusion

In this article, I have presented an example of a design that combines independent domain layer and Recoil so that only components that actually refer to the changed values are re-rendered. I hope this article will be helpful to anyone reading it for design ideas. And please share your own Recoil state layer design patterns with me!

Appendix

âš  Notes on class-based state module

For example, when other singleton instances are referenced in a class, if the references are interdependent, a deadlock occurs and the creation of that singleton instance fails, resulting in a runtime error.

Instance creation deadlock due to singleton instance cross-reference

Therefore, if you want to use the updated values of other modules in a particular state module to perform update processing, you will have to be creative.

The other caveat is that you should always implement all members as fields. If implemented as getter or method, using a singleton instance as follows will cause an error at runtime because this in the getter or method will be undefined.

import { fooState } from './state/FooState';

const {
  oneState,
  twoState,
  // ... many other states ...
  someCallback
} = fooState;

// ...

  // If `someCallback` is a method of `fooState`,
  // then `this` in `someCallback` is `undefined`.
  someCallback();

// ...
Enter fullscreen mode Exit fullscreen mode

Top comments (0)