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.
- Ensure domain layer independence
- 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.
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.
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?
- Prepare separate Recoil state values to store the values referenced by each component.
- Export only two types of elements from the state module.
-
RecoilValueReadOnly
to reference the state - Callback functions to update the state
-
- 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 atom
s 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);
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;
}
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,
};
- 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);
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();
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})
};
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));
};
}
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);
});
}
- 🚨 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 viasnapshot
afterset
, it will not be updated to the value you justset
, but will remain the same as it was beforeset
.
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;
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;
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>
);
});
🎉 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.
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.
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();
// ...
Top comments (0)