I recently "came up with" a new React design pattern. In this post, I just want to show you the method because it seems it will take a lot of time to write the whole background, problem and pros/cons.
For the large chunk of the process, it is relatively "easy" to separate them by considering the layer and/or their concern. However, it is not easy for the frontend components due to other kinds of problem. I (roughly) "propose" a new divide-and-conquer pattern named "View-Hook Pair" to solve them.
Large Frontend Component
Imagine the SPA app which has many large components; these components interact each other in the sense of the logic and UI. Whenever the user opens/closes <ProjectPane />
tab, its content should be look the same. In other words, the internal UI state of <ProjectPane />
should not be reset after its mounting and unmounting. To satisfy these UI requirements, we want to shape up the structure which the parent of <ProjectPane />
have bare minimum of the control.
Maybe we are going to achieve it by using Redux or some data store or by controlling all the states and logics (which ideally the parent should not concern) in the parent. It is difficult to separate them not just only styling the code but also improving the maintainability; loosely coupling and high cohesion.
View-Hook Pair Pattern
For the problem, I "propose" a new divide-and-conquer pattern named "View-Hook Pair". As the name suggests, a pair is made by React JSX and React Hook. The former only controls UI code and latter only controls states and logics. Both may be exported and used from outside. I think this kind of pattern is used in the public already. I just reorganize them as a bit abstract pattern.
Mainly a pair consists of items below. (The variable names in the example code has no special meaning.)
- UI code as View
- States & logics as Hook
- (if necessary) Integration code of the pair and/or Type definition of inter-pair-item interface
It could be regarded as the gradual Model-View-ViewModel(MVVM) pattern using Hook.
View
export const View = ({ enabled, enable, disable, /* ... */ }: PresentationModel) => (
<div>
<input type="text" disabled={disabled} />
<div>
<button type="button" onClick={enable}>Enable</button>
<button type="button" onClick={disable}>Disable</button>
</div>
</div>
);
In View item in the pair, it has all UI code in the component and written in a pure "state -> UI" function. It receives arguments from Hook result (in mind) and returns JSX.
By separating the component to the UI code only chunk, it is easy to do unit tests and make visual catalog like Storybook story. In a naïve View, it contains the structure definition (HTML/XML) and the style definition (CSS). So we can separate View more to the layers.
Hook
export const usePresentationModel = ({}: Args): PresentationModel => {
const [enabled, setEnabled] = useState(false);
const enable = useCallback(() => { setEnabled(true); }, []);
const disable = useCallback(() => { setEnabled(false); }, []);
// other definitions...
return { enabled, enable, disable, /* ... */ };
};
In Hook item in the pair, it has all the states and logics and written in a Custom Hook. It receives arguments of dependencies and/or initial values and returns values/callbacks to View in mind.
By separating the component to states and logics only chunk, it is easy to do unit tests. When the hook gets fat, we can separate Hook to sub hooks by concerns like the method described in useEncapsulation | Kyle Shevlin and/or putting a reducer or a data-access layer as plain "Model" (at least in the interface type) to the backward. Doing latter one, this Hook is regarded as "buffer zone" between React code and non-React code, like original MVVM ViewModel.
Basic form of integration
export const Container = () => {
const presentationModel = usePresentationModel();
return <View {...presentationModel} />;
};
The basic form of the integration of the pair is just passing the Hook result to View. It can be okay to do integration test with it.
The integration code should let pair-items concentrate on their concerns as much as possible.
Examples
It is not easy to explain the merits with a small code example because this pattern is for the large components. Here, I will show the example utilizing this pattern inside of the component or outside of the component. (These are not limited to the page component.)
As testable separation for the component
export const Page = ({ userName, changeTheme }: Props) => {
const { initialize, ...presentationModel } = usePageLogics({ changeTheme });
useEffect(() => { initialize(); }, []);
return <PageView {...presentationModel} userName={userName} />;
};
It is possible to use a part of result from Hook in useEffect
to call some process after Page
mount (initialize
in the example). It is okay to mix Hook result values with props and/or context; values not from Hooks. (No overuse!)
When to make more layers in usePageLogics
, do DI in Page
and avoid usePageLogics
depends directly on Context, Redux, Router or etc.
As described above, we can test both pair-items and integration code easily.
I reviewed and wrote a page component with this pattern in my work and the guy; who uses layered architecture in server side; said it is clear and easy to understand.
As divide-and-conquer in the component
const Page = () => {
const [isPaneOpen] = useState(false);
const projectListProps = useProjectList();
return (
<ProjectListContext.Provider value={projectListProps}>
<div>/* deep */
{isPaneOpen && <div><PaneContent /></div>}
/* deep */</div>
<ProjectListContext.Provider>
);
};
const PaneContent = () => {
const projectListProps = useContext(ProjectListContext);
return <div><ProjectList {...projectListProps} /></div>;
};
The problem; the difference of the desired place for UI code and the desired place for the data lifecycle; is solved by separating the component to the pair and place pair-items separately. In the example above, the state of <ProjectList />
will not change after toggling to isPaneOpen === false
condition. It is not necessary to prepare a global store and transform models only to achieve these requirements; keeping state and divide-and-conquer.
Of cource, we can mix Hook result values with a local state or something in the place among <Page />
and <LeftPane />
, so we can adjust values a bit easily. (In easy case, Unstated Next is helpful.)
Although View and Hook are placed separately in the parent component, we can do unit test and "integration test" by writing the integration code for the test.
Unfortunately, it is still in experimental phase for me because some questions below perhaps indicate warning and a next evolutional pattern.
Current questions
- (Like ViewModel in MVVM) The inter-pair-items interface type is exposed outside. This is good at adjusting but the same time, is it okay in sense of divide-and-conquer?
- If the component is enough small, the plain coupled form is easy and fast to implement. How to make a tradeoff?
- No example to split a large pair to the child pairs. Is it easy to split as same as a plain coupled component?
- Unknown potentials with using React Server Component. Is is still useful with it?
Top comments (1)
This is literally the smart/dumb pattern right? Dont get me wrong it is an excellent pattern that encourages separation of concerns, eases testing, and makes state easier to locate - but it is a well established setup.