Explicit Design, Part 7. App Composition without Hooks
Alex Bespoyasov
Posted on August 28, 2023
Let's continue experimenting with explicit software design.
In the previous posts, we've built the converter app from its parts, composed everything with hooks, discussed ways to simplify composition, and talked about various types of testing.
In this post, we'll take a small side road and discuss how to compose an app without hooks, how to inject dependencies “before runtime,” and whether there's any benefit to doing so.
But first, disclaimer
This is not a recommendation on how to write or not write code. I am not aiming to “show the correct way of coding” because everything depends on the specific project and its goals.
My goal in this series is to try to apply the principles from various books in a (fairly over-engineered) frontend application to understand where the scope of their applicability ends, how useful they are, and whether they pay off. You can read more about my motivation in the introduction.
Take the code examples in this series sceptically, not as a direct development guide, but as a source of ideas that may be useful to you.
By the way, all the source code for this series is available on GitHub. Give these repo a star if you like the series!
Problems with Hooks
This post is probably going to be the most subjective in the entire series.
My criticism of hooks is just my opinion, I could be wrong, and I am probably wrong. So before we start writing code, I want to explain the reasons why hooks have recently become less attractive to me as a tool.
High Infectiousness and Multiple Limitations
Hooks infect everything around them. If we decide to use a hook somewhere to solve a particular problem, we have to use them in all other parts of the code that are somehow related to that problem, even if they're not needed there.
Developing with hooks requires making too many decisions too early. We have to deal with low-level implementation details before it becomes really necessary.
In addition, hooks introduce not always justified restrictions, which can suddenly change for weakly substantiated reasons.
The volatile restrictions of a tool decrease trust in it, because it becomes expensive to maintain in the long term. Changing, such tools add extra work and inflate technical debt, consuming resources that could have been saved.
Vendor Lock-In
Hooks tightly bind the project to specific technologies and tools, making it excessively costly to switch them.
This may not be critical for every project, especially if the project is short-lived. But if we are going to write code that will live for 5+ years, we should allocate resources in advance to update the codebase and consider the likelihood of switching to another framework or library.
Implicit Dependencies and Leaking Abstractions
Hooks encourage combining data and behavior. Composing hooks leads to a composition of side effects, which is called one of the main problems of OOP, for example.
Hidden dependencies and the influence of effects on each other are difficult to grasp, making it harder to control program behavior.
By “encouragement” I mean not so much the examples from the documentation as the difference in how easy it is to write code by combining data with behavior versus not doing so. With hooks, the latter is even more difficult on a syntactical level.
Implementation details of hooks are often overly or insufficiently abstracted. A single hook may contain functionality from different abstraction levels, which requires mentally jumping between different levels while reading. This increases cognitive load and clouds the interaction between parts of the application.
For the same reasons, testing hooks can be difficult. Composing effects requires not only preparing input data for the hook but also “recreating its state,” and implicit dependencies require complex testing infrastructure. For example, to test such a hook:
const useUser = () => {
const { data, isLoading } = useSWR(['/users', id], fetchUser);
const role = useRoles(data);
const session = useStore((s) => s.session);
return { ...data, session, role };
};
For example, to test such a hook, we need to mock fetch
(or useSWR
), set up a store provider, check what useRoles
consists of, so that we can mock it or its dependencies if necessary.
Finally, since the composition of effects and excessive abstraction do not fit in our heads, we may forget to test edge cases: a specific user role, an incorrect server response, data revalidation, overwriting session data from old to new, etc.
As a result, we have to keep in mind not only the code of the hook itself but also many other aspects:
Complicated Mental Model
It is difficult to give a comprehensive definition of hooks, and in my observations, their mental model raises many questions for new developers.
They seem to be similar to functions, but behave differently. Conditions for re-rendering complicate the understanding of how component re-rendering works. The concept of hooks seems to be established, but details and rules can change drastically from version to version.
This, again, reduces confidence in the stability of the API and complicates learning.
Disclaimer
All of this doesn't mean that it's impossible to write good and well-structured code with hooks. It's possible, of course.
I just feel that if a technology imposes restrictions, they should guide developers and make it impossible or at least difficult to write code “incorrectly”. With hooks, however, I get the feeling that they don't provide a clear mental framework for understanding how to write code using them.
For me, hooks are a way of composing different functionality. I think of them as “injectors” of services, functions, and data that trigger component re-renders. If the functionality is not directly related to the UI state or component re-rendering, then I will first consider whether it can be written without using hooks.
By the way, in the new React documentation I found something similar to this idea.
Composition without Hooks
Now that we have aligned our understanding of hooks, let's try to rebuild the converter without using them. Since the application itself is already designed, we can immediately move on to choosing the appropriate tools for the task.
By the way, this section can be an example of why it's better to choose tools later, when you know as much as possible about the project. It's clear that our requirement “to be able to work without hooks” is artificial, but it can be replaced with a more significant requirement that is directly related to the business needs.
The service that requests data from the API doesn't use hooks, so we will leave it unchanged, but we will slightly modify the store. Instead of using context, we will use the Zustand library. It is a state manager that is somewhat similar to Redux, but simpler and doesn't require providers.
Store Service
After installing Zustand in the project, we can describe a basic implementation of the store using it:
// infrastructure/store.ts
export const converter = createStore<Converter>(() => ({
// ...Default model values.
}));
Next, let's describe the composition, that is, how this service will implement the application ports declared earlier:
// infrastructure/store.composition.ts
// Output ports connect the service
// with the use cases:
export const readConverter: ReadConverter = converter.getState;
export const saveConverter: SaveConverter = converter.setState;
// Input ports will be implemented directly,
// since there's no “domain logic” in the selectors:
export const useBaseValue: SelectBaseValue = () => useStore(converter, (vm) => vm.baseValue);
export const useQuoteCode: SelectQuoteCode = () => useStore(converter, (vm) => vm.quoteCode);
export const useQuoteValue: SelectQuoteValue = () => useStore(converter, (vm) => vm.quoteValue);
We will leave the data selectors unchanged. These are precisely the “reactive data” that should update the UI, so providing them through hooks makes sense.
On the other hand, the implementation of output ports will be used by use cases, which we will implement as functions. Therefore, readConverter
and saveConverter
will be references to read and write functions, not hooks.
Composition of Use Cases
Let's update the composition of use cases to use readConverter
and saveConverter
functions directly:
// core/updateBaseValue.composition
// ...
import { readConverter, saveConverter } from '../../infrastructure/store';
export const useUpdateBaseValue: Provider<UpdateBaseValue> = () => {
return useCallback(
(value) => updateBaseValue(value, { readConverter, saveConverter }),
[readConverter, saveConverter]
);
};
Since the imported functions won't change their references, we can remove the useCallback
:
// core/updateBaseValue.composition
import { readConverter, saveConverter } from '../../infrastructure/store';
export const useUpdateBaseValue: Provider<UpdateBaseValue> = () => {
return (value) => updateBaseValue(value, { readConverter, saveConverter });
};
After that, it will become clear that creating an extra lambda in the hook and passing dependencies to the updateBaseValue
function at runtime no longer makes sense. Instead, we will use “bake in” dependencies and prepare the entire use case in advance.
Currently, the code for the updateBaseValue
function looks like this:
// core/updateBaseValue
const stub = {} as Dependencies;
export const updateBaseValue: UpdateBaseValue = (
rawValue,
{ readConverter, saveConverter }: Dependencies = stub
) => {
// ...
};
We will change the function signature so that it can be partially applied by specifying dependencies. We will extract the dependency argument, put it first, and make the function “curried”:
// core/updateBaseValue
export const createUpdateBaseValue =
({ readConverter, saveConverter }: Dependencies): UpdateBaseValue =>
(rawValue) => {
// ...
};
I have covered dependency “baking” in more detail in a previous post about infrastructure. If you're not quite clear on what's happening here, I recommend reading that post first.
Also, technically it's not quite accurate to call this “currying” in JS, but we won't delve into terminology here. Also, the current way to make the function partially applicable is somewhat cumbersome. However, when native partial application is introduced to JS, working with this concept will be slightly easier.
Next, we can partially apply the factory function by passing in the dependency argument and obtain a prepared use case:
// core/updateBaseValue.composition
export const updateBaseValue: UpdateBaseValue = createUpdateBaseValue({
readConverter,
saveConverter
});
As we mentioned earlier, partial application is more type-safe than an optional argument with dependencies, so we have less chance of passing the wrong service or forgetting to pass it. And since the real values are substituted only once, such composition should not affect performance.
Composition of Components
Since the use case is now just a function, components can use it directly:
// ui/BaseValueInput
type BaseValueInputDeps = {
// Using the function directly:
updateBaseValue: UpdateBaseValue;
useBaseValue: SelectBaseValue;
};
// In the component itself, we'll remove the `useUpdateBaseValue` call
// and will use the given `updateBaseValue` function directly.
The composition of the component itself will not change significantly:
// ui/BaseValueInput.composition
// ...Import the function:
import { updateBaseValue } from '../../core/updateBaseValue';
export const BaseValueInput = () =>
// ...Pass it when “registering” the component:
Component({ updateBaseValue, useBaseValue });
Let's do the same with other components that depend on this use case.
Again, if we don't use explicit composition, it would be enough to import and use the function directly in the component. More details about explicit and implicit composition can be found in one of the previous posts.
Composition of Tests
Since we are not touching the logic, in tests, we only need to update the preparation of stubs and mocks:
// core/updateBaseValue.test
const readConverter = () => ({ ...converter });
const saveConverter = vi.fn();
const updateBaseValue = createUpdateBaseValue({
readConverter,
saveConverter
});
// ui/BaseValueInput.test
const updateBaseValue = vi.fn();
const useBaseValue = () => 42;
const dependencies = {
updateBaseValue,
useBaseValue
};
The test code and its logic will remain unchanged.
Exchange Rates Refresh
We can do the same with the update quotes use case. First, we change the function's signature to be prepared for the partial application:
// core/refreshRates
export const createRefreshRates =
({ fetchRates, readConverter, saveConverter }: Dependencies): RefreshRates =>
async () => {
//...
};
Next, we can partially apply it, passing freshly created functions for working with the store as dependencies:
// core/refreshRates.composition
import { readConverter, saveConverter } from '../../infrastructure/store';
import { fetchRates } from '../../infrastructure/api';
export const refreshRates: RefreshRates = createRefreshRates({
fetchRates,
readConverter,
saveConverter
});
After this, we need to decide how we want to work with the asCommand
adapter and update its code. For example, we want the use case to be independent and work without hooks, but we want to see the reactive status of the operation in the UI.
Then we can rewrite asCommand
so that it turns the use case function into a hook that returns the { result, execute }
interface:
// shared/infrastructure/cqs
export const asCommand =
<F extends AsyncFn>(command: F): Provider<Command<F>> =>
() => {
// ...
const execute = async () => {
// ...
};
return { result, execute };
};
The component in this case will continue depending on a hook:
// ui/RefreshRates
type RefreshRatesProps = {
useRefreshRates: Provider<Command<RefreshRates>>;
};
...But at composition time, we can provide a regular function to a component, the adapter will transform it into a hook:
// ui/RefreshRates.composition
import { refreshRates } from '../../core/refreshRates';
import { asCommand } from '~/shared/infrastructure/cqs';
export const RefreshRates = () => Component({ useRefreshRates: asCommand(refreshRates) });
Other Tools
In this example, we chose Zustand as our state manager because it is suitable for working with objects where multiple fields can be inter-related. In other applications, we might need other tools, such as Jotai or MobX.
In the repository, I left a couple of examples of how to implement the store using these two libraries as well.
Sources and References
Links to books, articles, and other materials I mentioned in this post.
React Hooks and Components
State and Effect Management
Abstractions and Decomposition
- Abstraction as a design tool
- Functional architecture - The pits of success
- Referential transparency fits in your head
Infrastructure Tools
Other Topics
- Curried functions
- Partial Application Syntax for ECMAScript
- Unit Testing: Principles, Practices, and Patterns by Vladimir Khorikov
P.S. This post was originally published at bespoyasov.me. Subscribe to my blog to read posts like this earlier!
Posted on August 28, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
August 21, 2023