Introduction to the Solid JavaScript Library
Solid is a reactive JavaScript library for creating user interfaces without a virtual DOM. It compiles templates down to real DOM nodes once and wraps updates in fine-grained reactions so that when state updates, only the related code runs.
This way, the compiler can optimize initial render and the runtime optimizes updates. This focus on performance makes it one of the top-rated JavaScript frameworks.
I got curious about it and wanted to give it a try, so I spent some time creating a small to-do app to explore how this framework handles rendering components, updating state, setting up stores, and more.
Here’s the final demo if you just can’t wait to see the final code and result:
Getting started
Like most frameworks, we can start by installing the npm package. To use the framework with JSX, run:
npm install solid-js babel-preset-solid
Then, we need to add babel-preset-solid
to our Babel, webpack, or Rollup config file with:
"presets": ["solid"]
Or if you’d like to scaffold a small app, you can also use one of their templates:
# Create a small app from a Solid template
npx degit solidjs/templates/js my-app
# Change directory to the project created
cd my-app
# Install dependencies
npm i # or yarn or pnpm
# Start the dev server
npm run dev
There is TypeScript support so if you’d like to start a TypeScript project, change the first command to npx degit solidjs/templates/ts my-app
.
Creating and rendering components
To render components, the syntax is similar to React.js, so it might seem familiar:
import { render } from "solid-js/web";
const HelloMessage = props => <div>Hello {props.name}</div>;
render(
() => <HelloMessage name="Taylor" />,
document.getElementById("hello-example")
);
We need to start by importing the render
function, then we create a div with some text and a prop, and we call render
, passing the component and the container element.
This code then compiles down to real DOM expressions. For example, the code sample above, once compiled by Solid, looks something like this:
import { render, template, insert, createComponent } from "solid-js/web";
const _tmpl$ = template(`<div>Hello </div>`);
const HelloMessage = props => {
const _el$ = _tmpl$.cloneNode(true);
insert(_el$, () => props.name);
return _el$;
};
render(
() => createComponent(HelloMessage, { name: "Taylor" }),
document.getElementById("hello-example")
);
The Solid Playground is pretty cool and shows that Solid has different ways to render, including client-side, server-side, and client-side with hydration.
Tracking changing values with Signals
Solid uses a hook called createSignal
that returns two functions: a getter and a setter. If you’re used to using a framework like React.js, this might seem a little weird. You’d normally expect the first element to be the value itself; however in Solid, we need to explicitly call the getter to intercept where the value is read in order to track its changes.
For example, if we’re writing the following code:
const [todos, addTodos] = createSignal([]);
Logging todos
will not return the value, but a function instead. If we want to use the value, we need to call the function, as in todos()
.
For a small todo list, this would be:
import { createSignal } from "solid-js";
const TodoList = () => {
let input;
const [todos, addTodos] = createSignal([]);
const addTodo = value => {
return addTodos([...todos(), value]);
};
return (
<section>
<h1>To do list:</h1>
<label for="todo-item">Todo item</label>
<input type="text" ref={input} name="todo-item" id="todo-item" />
<button onClick={() => addTodo(input.value)}>Add item</button>
<ul>
{todos().map(item => (
<li>{item}</li>
))}
</ul>
</section>
);
};
The code sample above would display a text field and, upon clicking the “Add item” button, would update the todos with the new item and display it in a list.
This can seem pretty similar to using useState
, so how is using a getter different? Consider the following code sample:
console.log("Create Signals");
const [firstName, setFirstName] = createSignal("Whitney");
const [lastName, setLastName] = createSignal("Houston");
const [displayFullName, setDisplayFullName] = createSignal(true);
const displayName = createMemo(() => {
if (!displayFullName()) return firstName();
return `${firstName()} ${lastName()}`;
});
createEffect(() => console.log("My name is", displayName()));
console.log("Set showFullName: false ");
setDisplayFullName(false);
console.log("Change lastName ");
setLastName("Boop");
console.log("Set showFullName: true ");
setDisplayFullName(true);
Running the above code would result in:
Create Signals
My name is Whitney Houston
Set showFullName: false
My name is Whitney
Change lastName
Set showFullName: true
My name is Whitney Boop
The main thing to notice is how My name is ...
is not logged after setting a new last name. This is because at this point, nothing is listening to changes on lastName()
. The new value of displayName()
is only set when the value of displayFullName()
changes, this is why we can see the new last name displayed when setShowFullName
is set back to true
.
This gives us a safer way to track values updates.
Reactivity primitives
In that last code sample, I introduced createSignal
, but also a couple of other primitives: createEffect
and createMemo
.
createEffect
createEffect
tracks dependencies and runs after each render where a dependency has changed.
// Don't forget to import it first with 'import { createEffect } from "solid-js";'
const [count, setCount] = createSignal(0);
createEffect(() => {
console
Count is at...
logs every time the value of count()
changes.
createMemo
createMemo
creates a read-only signal that recalculates its value whenever the executed code’s dependencies update. You would use it when you want to cache some values and access them without re-evaluating them until a dependency changes.
For example, if we wanted to display a counter 100 times and update the value when clicking on a button, using createMemo
would allow the recalculation to happen only once per click:
function Counter() {
const [count, setCount] = createSignal(0);
// Calling `counter` without wrapping it in `createMemo` would result in calling it 100 times.
// const counter = () => {
// return count();
// }
// Calling `counter` wrapped in `createMemo` results in calling it once per update.
// Don't forget to import it first with 'import { createMemo } from "solid-js";'
const counter = createMemo(() => {
return count()
})
return (
<>
<button onClick={() => setCount(count() + 1)}>Count: {count()}</button>
<div>1. {counter()}</div>
<div>2. {counter()}</div>
<div>3. {counter()}</div>
<div>4. {counter()}</div>
<!-- 96 more times -->
</>
);
}
Lifecycle methods
Solid exposes a few lifecycle methods, such as onMount
, onCleanup
and onError
. If we want some code to run after the initial render, we need to use onMount
:
// Don't forget to import it first with 'import { onMount } from "solid-js";'
onMount(() => {
console.log("I mounted!");
});
onCleanup
is similar to componentDidUnmount
in React — it runs when there is a recalculation of the reactive scope.
onError
executes when there’s an error in the nearest child’s scope. For example we could use it when fetching data fails.
Stores
To create stores for data, Solid exposes createStore
which return value is a readonly proxy object and a setter function.
For example, if we changed our todo example to use a store instead of state, it would look something like this:
const [todos, addTodos] = createStore({ list: [] });
createEffect(() => {
console.log(todos.list);
});
onMount(() => {
addTodos("list", [
...todos.list,
{ item: "a new todo item", completed: false }
]);
});
The code sample above would start by logging a proxy object with an empty array, followed by a proxy object with an array containing the object {item: "a new todo item", completed: false}
.
One thing to note is that the top level state object cannot be tracked without accessing a property on it — this is why we’re logging todos.list
and not todos
.
If we only logged todo
` in createEffect
, we would be seeing the initial value of the list but not the one after the update made in onMount
.
To change values in stores, we can update them using the setting function we define when using createStore
. For example, if we wanted to update a todo list item to “completed” we could update the store this way:
const [todos, setTodos] = createStore({
list: [{ item: "new item", completed: false }]
});
const markAsComplete = text => {
setTodos(
"list",
i => i.item === text,
"completed",
c => !c
);
};
return (
<button onClick={() => markAsComplete("new item")}>Mark as complete</button>
);
Control Flow
To avoid wastefully recreating all the DOM nodes on every update when using methods like .map()
, Solid lets us use template helpers.
A few of them are available, such as For
to loop through items, Show
to conditionally show and hide elements, Switch
and Match
to show elements that match a certain condition, and more!
Here are some examples showing how to use them:
<For each={todos.list} fallback={<div>Loading...</div>}>
{(item) => <div>{item}</div>}
</For>
<Show when={todos.list[0].completed} fallback={<div>Loading...</div>}>
<div>1st item completed</div>
</Show>
<Switch fallback={<div>No items</div>}>
<Match when={todos.list[0].completed}>
<CompletedList />
</Match>
<Match when={!todos.list[0].completed}>
<TodosList />
</Match>
</Switch>
Demo project
This was a quick introduction to the basics of Solid. If you’d like to play around with it, I made a starter project you can automatically deploy to Netlify and clone to your GitHub by clicking on the button below!
This project includes the default setup for a Solid project, as well as a sample Todo app with the basic concepts I’ve mentioned in this post to get you going!
There is much more to this framework than what I covered here so feel free to check the docs for more info!
The post Introduction to the Solid JavaScript Library appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.