DEV Community

Cover image for Explicit Design, Part 7. App Composition without Hooks
Alex Bespoyasov
Alex Bespoyasov

Posted on • Originally published at bespoyasov.me

Explicit Design, Part 7. App Composition without Hooks

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 };
};
Enter fullscreen mode Exit fullscreen mode

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:

The hidden complexity of hooks can be too high, and there are no natural boundaries for it

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.
}));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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]
    );
};
Enter fullscreen mode Exit fullscreen mode

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 });
};
Enter fullscreen mode Exit fullscreen mode

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
) => {
    // ...
};
Enter fullscreen mode Exit fullscreen mode

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) => {
        // ...
    };
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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 () => {
        //...
    };
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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 };
    };
Enter fullscreen mode Exit fullscreen mode

The component in this case will continue depending on a hook:

// ui/RefreshRates

type RefreshRatesProps = {
    useRefreshRates: Provider<Command<RefreshRates>>;
};
Enter fullscreen mode Exit fullscreen mode

...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) });
Enter fullscreen mode Exit fullscreen mode

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

Infrastructure Tools

Other Topics

P.S. This post was originally published at bespoyasov.me. Subscribe to my blog to read posts like this earlier!

Top comments (0)