Reflections on SolidJS application architecture

Vladislav Lipatov
5 min readJul 12, 2023

--

Preface

In this article I will not talk about the pros and cons of using SolidJS, but will focus on the issues of organizing the structure of an application.

SolidJS gives us many ways to write code because components in SolidJS are just a way of grouping code, just functions. State is not tied to “components”, so the term is rather formal in SolidJS. This freedom can sometimes be confusing, because you can write the same thing in different ways and everything will work. In addition, it would be great to keep a fast pace of developing new functionality while avoiding making the architecture more complex over time so that the code is easy to maintain. When developing an application in SolidJS, we may have questions:

  • Should we leave the logic inside the “components” or take it outside?
  • How do we separate functionality so that the code is easy to maintain?
  • How not to get confused by derived signal dependencies?
  • When to initialize certain functionality?
  • How to easily scale in the future by adding new functionality?
  • How to maintain a unified structure on a project to make it harder for developers to make mistakes?
  • How to avoid loops in the signal dependency graph?

These questions really make you think. In this article I will tell you about my approach that answers these questions, its advantages and disadvantages.

Building the architecture

Final goal

Let’s look at an example application that implements n different functionalities.

Most likely, we want our application to look like this:

Structured architecture

And not like this:

Sphagetti code

In the case of an acyclic graph, we can easily trace the dependencies of a particular functionality.

SolidJS concerns

In SolidJS we can separate the logic from the view by, for example, creating a global stack (createRoot) or by moving this logic to a separate component that does not do layout. What should we choose?

Before looking at both options, we should consider some of the peculiarities of SolidJS development. In the application, we use state, which can be a reactive primitive (signal), or a derived state that has some dependencies. It is very important in this approach to avoid loops (when state A depends on state B, and state B in turn depends on state A), because there is a chance to go into an infinite loop or create bugs.

Simply put, it is desirable to keep the hierarchy of data and dependencies for better debugging, predictable behavior and easier development.

Global stores approach (createRoot)

I’ve noticed that if you use the createRoot approach, you can quickly get confused importing stores in different components. Of course, this can be really useful when we want to make some application logic available to everything at once. However, this approach is not always applicable. By importing global stores in different places, we quickly start to lose control over dependencies as imports multiply and it becomes harder to keep track of the dependency hierarchy. Besides, a reasonable question arises: when to initialize or destroy some functionality while freeing memory?

Contexts come to the rescue!

Advantages of Context-based approach

If you don’t use global stores, you can wrap each functionality into a component that will deal only with specific business logic, while passing the necessary data through the context to all descendants. Since SolidJS does not have the concept of re-renderers, we can fearlessly perform operations on data directly inside components.

Isolated logic

Each component encapsulates a certain functionality within itself, providing the necessary API to the outside through context. Therefore, it is quite easy to add new functionality without touching existing functionality: create a new service that will provide APIs via context provider.

Clear dependency hierarchy

If one functionality (feature A) depends on another functionality (feature B), it must be lower down the component tree in order to use the API via context. For example:

const FeatureBService = (props) => (
<FeatureBContext.Provider value={featureBAPI}>
{props.children}
</FeatureBContext.Provider>
)

const FeatureAService = (props) => {
// Turns out that useContext is some kind of DI here
const featureBAPI = useContext(FeatureBContext);

// do stuff here with featureBAPI

return (
<FeatureAContext.Provider value={featureAAPI}>
{props.children}
</FeatureAContext.Provider>
);
}

const App = () => (
<FeatureBService>
<FeatureAService>
...
</FeatureAService>
</FeatureBService>
)

That said, we’ll notice right away if we get dependency loops, because then we can’t organize the context hierarchy properly. In this case, it may be worth reconsidering the architecture:

  • Either refactor the existing logic, getting rid of the dependency loop
  • Or combine functionality into a single service

Thus, we can create a unidirectional data flow and avoid loops, and thanks to the mechanism of contexts, it will be difficult to break the hierarchy, because we will immediately see in the console that a component cannot find a context (and everything will be broken)!

Predictable initialization of logic and its clearing

Since each functionality is a component, it is initialized when this component is rendered, and onCleanup we can clear the memory. In this way we can correctly and on demand initialize logic that is needed only when rendering some specific UI area. Example:

import {Show, onCleanup} from 'solid-js';

const CardService1 = (props) => {
// kind of DI
const api1 = useContext(SomeServiceContext1);
const api2 = useContext(SomeServiceContext2)

// initialize here or on mount

onCleanup(() => {
// release some memory here
});

return (
<CardService1Context.Provider value={cardService1API}>
{props.children}
</CardService1Context.Provider>
);
}

const SomeComponent = () => {

...

return (
<section>
{/* Render some card conditionally */}
<Show when={isCardVisible()}>
<CardService1>
<CardService2>
...
</CardService2>
</CardService1>
</Show>
</section>
);
}

Don’t forget to use code splitting to avoid importing unnecessary stuff when the component is not rendering :)

Development consistency

Adding new functionality is quite uniform, so it’s easy to understand another developer’s code:

  • All dependencies of a particular service are specified at the beginning of the component (via useContext)
  • Service API is described using the context type: createContext<ServiceAPI>()

Disadvantages of Context-based approach

The following disadvantages are not critical in my opinion, but this is a rather subjective opinion.

Context is available to everyone down the tree

Any component that is lower down the tree will be able to use the parent’s context. Probably the development team should agree on where and when contexts should be used or write some abstraction that will forbid the use of contexts outside of some special components (if this is critical).

Visual inconvenience

You are likely to encounter the following situation:

const App = () => (
<Service1>
<Service2>
<Service3>
<Service4>
<Service5>
{...}
</Service5>
</Service4>
</Service3>
</Service2>
</Service1>
);

In this case, you can:

  • Wrap a group of services in a single component
  • Separate groups of services by comments

Personally, this is not a big problem for me, although many may disagree with me :)

Conclusions

I have been using the context-based approach for a couple of years now and I can say that it is quite fast and convenient to develop functionality. Besides, with this approach it becomes difficult to write incorrectly and break the hierarchy.

Share your thoughts and approaches in comments, I will be glad to know your opinion!

--

--