SolidJS pain points and pitfalls

Vladislav Lipatov
7 min readOct 6, 2023

In this article, I will describe various non-obvious aspects of working with SolidJS, as well as inconveniences that I have encountered over the years of working with SolidJS. In my opinion, the main disadvantage of SolidJS is the lack of iterations of the library. Of course, the API, functionality, interfaces — all this will be redesigned with the release of new major versions of the library, and many of the points described in the article will lose their relevance.

Despite all these problems, I still choose Solid because, in my opinion, it solves more problems than it creates.

ClassList

Let’s start simple. At first glance, it’s a very useful functionality, finally we don’t need clsx!.. Oh, no… We do.

Very often we want to forward a class to a child component. If we don’t use clsx, we’ll write something like this:

const Component = (props) => {
// make TS happy
return <div class="component-class" classList={{[props.class ?? '']: true}}>
{/* something here */}
</div>
}

Or this:

const Component = (props) => {
// we don't want to see `undefined` in the class, right?
return <div class={`${props.class ?? ''} component-class`}>
{/* something here */}
</div>
}

The documentation says that using class and classList together is not recommended because it can lead to bugs:

It’s also possible, but dangerous, to mix class and classList.

Therefore, download clsx and use it:

const Component = (props) => <div class={cn("component-class", props.class)}>
{props.children}
</div>;

Directives

SolidJS implements directives, but they are made so awkwardly that I have not met people who actually use them. The main problem is not even that you need to override JSX types like this:

declare module "solid-js" {
namespace JSX {
interface Directives { model: [() => any, (v: any) => any]; }
}
}

The main problem is that you can’t put directives in one file and use them in another, because the TSC will simply cut them out of the code:

import {coolDirective} from '@directives/cool-directive';

// don't forget to add this line and the bundler will keep the function
coolDirective;

// somewhere inside a component
<div use:coolDirective={someSignal}>

However, it’s not the case if you work with JS instead of TS, but still keep this in mind.

Can directives be used? Can. Will they work? They will. Will you like the way it’s written? Not really.

Props wrapping

For me this thing is a pitfall, so you should be aware of this when writing your code. It’s just how SolidJS works. Imagine you have the following code:

const ChildComponent = (props) => {
return <button type="button" onClick={() => console.log(props.p)}>Click</button>
}

const generateStaticParameter = () => Math.random();

const ComponentA = () => {
return <ComponentB p={generateStaticParameter()} />
}

Every time you click a button you’ll see different value in the console. Why?! The function generateStaticParameter is called only once, why do I have different values? The truth is that the function generateStaticParameter is called every time you access props.p, because Solid compiles your code to this:

const ComponentA = () => {
return _$createComponent(ComponentB, {
get p() {
return generateStaticParameter();
}
});
};

So don’t forget to use @once if you need this or write something like this:

const ComponentA = () => {
const param = generateStaticParameter();

return <ComponentB p={param} />
}

Untrack reactive things

You can imagine a situation where we call a callback on a component directly inside createEffect:

// Somewhere inside a component
createEffect(() => {
if (someCondition()) {
props.onEventHandler();
}
})

It looks quite ordinary, but do not underestimate the danger of such code. The fact is that callback onEventHandler can use signals within itself, for example:

const [listenToEvents] = createSignal(true);

const onEventHandler = () => {
if (listenToEvents()) {
// do some stuff
}
}

In this case, createEffect will also subscribe to changes in the listenToEvents signal! You should always keep this in mind when you call a function inside effects. To avoid potential problems, you should wrap the function call in untrack:

// Somewhere inside a component
createEffect(() => {
if (someCondition()) {
untrack(() => props.onEventHandler())
}
})

If you are the author of a library for SolidJS and you provide some kind of API that uses signals under the hood, then be sure to wrap your functions in untrack (or wrap the parts where you work with signals), because users of your library will most likely they won’t do this. Example (from the elite174/solid-undo-redo library):

// https://github.com/elite174/solid-undo-redo/blob/master/src/lib/travel.ts
const redo = () => {
const pointer = untrack(currentNodePointer);
const nextPointer = pointer.next;

if (!pointer || !nextPointer) return;

undoCount--;
setCurrentNodePointer(nextPointer);

untrack(() =>
callbackMap.redo.forEach((callback) =>
callback(nextPointer.value, pointer.value)
)
);
};

onCleanup inside a resource

When you need to load data from the server, it is important to use the AbortController to abort the request when a component is remounted or, for example, a request parameter changes. We could write something like this:

const [data] = createResource(paramSingal, async (currentParam) => {
const abortController = new AbortController();

const data = await fetch('...', {signal: abortController.signal});

// Cancel request when component unmounts or when `paramSignal` value changes
onCleanup(() => abortController.abort());

return data;
})

It seems that everything is quite logical… However, this example will not work (and you should get a warning in the console). Why? Due to the way Solid works (namely synchronous subscriptions), you must call onCleanup before await:

const [data] = createResource(paramSingal, async (currentParam) => {
const abortController = new AbortController();
// Now it works
onCleanup(() => abortController.abort());

const data = await fetch('...', {signal: abortController.signal});

// dead zone
// no effects here

return data;
})

Resource mutation

Let’s look at an artificial example where we get information about the user, and then click on a button and change his photo. In this example, the data structure is intentionally nested to show problems.

// Let's imagine we have this artificial data structure
type User = {
name: string;
misc: {
photo: {
// imageURL for the book
url: string;
}
}
}

const Component = () => {
// fetch user data
const [user, {mutate}] = createResource<User>(async () => await fetchUser());

const handleButtonClick = async () => {
const newImageURL = await fetchNewImage();

// wait... how???
mutate(...)
}

return (
<p>
{/* Of course we want to show fancy loader while we wait for the data */}
<Suspense fallback={<FancyLoader/>}>
<img src={user().misc.photo.url} alt={user().misc.photo.cation}/>
</Suspense>
<button type="button" onClick={handleButtonClick}>
Change user's photo
</button>
</p>
);
}

createResourse provides a mutate method with which we can update the data signal, however we need to create a completely new object in order to apply the changes. The thing is that data in this case is a signal, not store, so we cannot granularly change only one field in an object. This results in us needing to create a new object with an updated url field to reflect the changes but may result in unnecessary runs of other effects that use other object fields (because they track fields by reference).

To solve this problem, an experimental storage option was added to Solid 1.5, which allows resource data to be stored differently:

// taken from https://www.solidjs.com/docs/latest/api#createresource
function createDeepSignal<T>(value: T): Signal<T> {
const [store, setStore] = createStore({ value });

return [
() => store.value,
(v: T) => {
const unwrapped = unwrap(store.value);

typeof v === "function" && (v = v(unwrapped));
setStore("value", reconcile(v));
return store.value;
}
] as Signal<T>;
}

const [resource] = createResource(fetcher, { storage: createDeepSignal });

See the problem? In this case, the storage type is Signal<T>, which imposes restrictions on developers who are forced to adapt to the interface. In my opinion, the problem here lies in the fact that storage is encapsulated in resource, when instead we could separate the logic for storing data from retrieving it and apply composition.

Of course, there are solutions to this problem, although I personally don’t like any of them:
1. You can cheat typescript in places.
2. You can use createMutable (which can be dangerous because you can change the object at any time):

const [data] = createResource(() => { 
const res = await fetch();

return createMutable(res)
})

data().list.push({})

3. You can move the data to store and not use createResource at all, but then you will have to implement the pending, refreshing, etc. states yourself, and also take into account the fact that Suspense will not work.

Resource in transitions

If you work with a SolidJS router, you’ve probably implemented data functions (functions that load the necessary data for a page while the page code is loading). In data functions we can create resources that will then go to the page. Let’s imagine that our data depends on some searchParams in the URL, so when we change searchParams, the data is updated. When a data refresh occurs, we want to show the user that data is being loaded (we use the refreshing class on span):

 // The data function looks at the search params
// and tracks `filter` search param:
// If `filter` changes, resource will be refetched.
const itemsPageDataFunction = () => {
const [searchParams] = useSearchParams();

// Look at the filter search param
const [data] = createResource(() => searchParams['filter'], async (filterValue) => {
// refetch the data here according to new filterValue
return await fetchData(filterValue);
})

return data;
}

//... inside another file
const ItemsPage = () => {
const [_, setSearchParams] = useSearchParams();

const data = useRouteData();

const handleButtonClick = () => {
// trigger resource fetching
setSearchParams({filter: Math.random()});
}

return <Suspense>
{/* Show that we're updating the data*/}
<span classList={{refreshing: data.state === 'refreshing'}}>{data()}</span>
<button type="button" onClick={handleButtonClick}>
Trigger data update
</button>
</Suspense>
}

// ... inside another file
// somewhere inside router
<Route path="/items" component={LazyItemsPage} data={itemsPageDataFunction}/>

In this example, when we click on the button, we will not see the styles of the refreshing class, because the call to setSearchParams occurs in transition (like any router navigation action), which is why signal changes are ignored until transition is finished.

To solve this problem, you will need to monitor the isRouting signal and compare the old filter value and the new one.

Conclusion

For me the most weakest point of Solid is async stuff. Transitions are still buggy, it’s difficult to debug `Suspense` triggers… But anyways Solid is good.

Many things will be fixed and addressed in Solid 2.0 (SPOILER: we’ll have createAsync).

What are your pain points? Share in the comments!

--

--