TypeOfNaN

Learn Solid.js and build a todo list app

Nick Scialli
February 19, 2022

solid logo

Solid.js is a front-end framework that has been getting a lot of hype recently—and for good reason. On the surface, Solid looks similar to React, but its core mechanics are quite different. These differences make Solid blazingly fast and, in my opinion, less prone to some of the errors that plague React apps.

In this post, I’ll show you how to build a todo list app with Solid and explain some of the mechanics along the way. Why a todo list? Well, because you’ve probably made 100 of these and know exactly what goes into making one—leaving the framework itself as the only “unknown.”

Mocking up our app

First, we’ll mock up our application. The following mock-up shows three parts of our application:

  • TodoList
  • TodoListItem
  • AddTodoForm

mock-up

We can see that we have a checkbox to toggle whether an item is done or not. When the item is done, there’s a line through it. We’re also able to add new items using a textbox with an “Add” button.

Before we dive directly into the app, let’s get our local development environment working and go through some Solid basics.

Getting started with Solid

The easiest way to get started with Solid is to use one of their starter templates. Assuming you have node installed, run the following command in your terminal to download the Solid/JavaScript/Vite template into a directory called solid-todo-list:

npx degit solidjs/templates/js solid-todo-list

Next, change into the new directory, install the dependencies, and start the app.

cd solid-todo-list
npm i
npm run dev

If you navigate to http://localhost:3000, you should see a default Solid app running (yes, this is a play on the default create-react-app starter):

default Solid app

Some Solid principles

Inside our app directory, open up src/App.jsx in your development environment of choice. It’s JSX, which you’ll be pretty familiar with if you’ve used React.

Let’s delete everything in this file, and replace it with some Hello World text.

App.jsx

function App() {
  return (
    <div>
      <h1>Hello world!</h1>
    </div>
  );
}

export default App;

If we save this and go back to http://localhost:3000, we’ll see that Vite has helpfully updated our app in realtime using hot module replacement.

Next, let’s add a variable to the equation. Declare your name outside the function scope and greet yourself like this:

const name = 'Nick';

function App() {
  return (
    <div>
      <h1>Hello {name}!</h1>
    </div>
  );
}

export default App;

If you go back to the app, you should see yourself being greeted. Great!

If you’re used to React, you’re probably thinking “this all looks very similar.” And it is! Solid uses a lot of what’s great about React, including JSX.

But things are about to start getting different.

Let’s add a stateful variable: a count.

We can do this using createSignal, which may remind you of useState if you’re coming from React-land.

import { createSignal } from 'solid-js';

function App() {
  const [count, setCount] = createSignal(0);

  return (
    <div>
      <h1>The count: {count()}</h1>
    </div>
  );
}

export default App;

This may look familiar, but you’re wondering why count is a function. We’ll talk about this shortly!

If we go back to our application, we see that our count is 0. This makes sense—we never update it. Let’s add a button that allows us to do so.

import { createSignal } from 'solid-js';

function App() {
  const [count, setCount] = createSignal(0);

  return (
    <div>
      <h1>The count: {count()}</h1>
      <button
        onClick={() => {
          setCount(count() + 1);
        }}
      >
        Increment
      </button>
    </div>
  );
}

export default App;

When we go back to the app, we see that we can now increment the count by clicking the “Increment” button. To accomplish this, button has an onClick handler inside of which we use the setCount function given to us by createSignal.

incrementing

Granular reactivity

This is where things start getting pretty cool. Despite incrementing the count, we’re actually not re-running the App function. We can prove this by adding a console.count inside the function body:

import { createSignal } from 'solid-js';

function App() {
  const [count, setCount] = createSignal(0);

  console.count('here');

  return (
    <div>
      <h1>The count: {count()}</h1>
      <button
        onClick={() => {
          setCount(count() + 1);
        }}
      >
        Increment
      </button>
    </div>
  );
}

export default App;

No matter how many times we click the “Increment” button, all we see in the console is:

here: 1

So what’s going on here? It’s related to why our count variable is being called. When count is called, Solid knows that the component in which count is called should subscribe to any changes to count. This is happening at a very low level: there is no virtual DOM reconciliation, Solid is directly updating the DOM element that’s subscribed to the count value.

This can be confusing for React developers that may be inclined to rely on the App function body to re-run, but Solid’s model has a lot of benefits.

For one, we don’t have to worry about weird effects and concerns over infinite loops due to continuously re-running a component function. If you’re a React developer, the following code might terrify you. But it’s perfectly fine in Solid!

import { createSignal } from 'solid-js';

function App() {
  const [count, setCount] = createSignal(0);

  setInterval(() => {
    setCount(count() + 1);
  }, 1000);

  return (
    <div>
      <h1>The count: {count()}</h1>
    </div>
  );
}

export default App;

If that blows your mind, then you probably won’t believe this:

import { createSignal } from 'solid-js';

const [count, setCount] = createSignal(0);

setInterval(() => {
  setCount(count() + 1);
}, 1000);

function App() {
  return (
    <div>
      <h1>The count: {count()}</h1>
    </div>
  );
}

export default App;

And it still works! The signal created by createSignal doesn’t really belong to a component and it doesn’t need tobe scoped to one. Solid still knows that the text incremented in our header needs to subscribe to count, and that’s that.

Effects

Solid has effects that can run when a variable that effect depends on is updated. Now here’s the really cool part: the same mechanics that allow low-level reactivity also make it so we don’t have to declare dependencies for effects—they just know!

import { createSignal, createEffect } from 'solid-js';

const [count, setCount] = createSignal(0);

setInterval(() => {
  setCount(count() + 1);
}, 1000);

createEffect(() => {
  console.log(count());
});

function App() {
  return (
    <div>
      <h1>The count: {count()}</h1>
    </div>
  );
}

export default App;

And we’ll log the count every time it’s updated—with no dependency arrays necessary!

Organizing components

Much like React, we generally don’t stuff all our code in a single component: we stitch multiple together. Using our contrived counter example, we might have a separate Counter component to contain the number that increments:

import { createSignal } from 'solid-js';

function App() {
  const [count, setCount] = createSignal(0);

  setInterval(() => {
    setCount(count() + 1);
  }, 1000);

  return (
    <div>
      <h1>
        The count: <Counter count={count()} />
      </h1>
    </div>
  );
}

function Counter(props) {
  return <span>{props.count}</span>;
}

export default App;

Beware the metaprogramming

Nothing’s free. This is also true for Solid.js. Solid is so efficient because it intercepts count’s getter and can update the span component. However, you can accidentally access the getter too early and end up failing to have a DOM element subscribe to the signal as desired. Consider the following, seemingly-similar code:

import { createSignal } from 'solid-js';

function App() {
  const [count, setCount] = createSignal(0);

  setInterval(() => {
    setCount(count() + 1);
  }, 1000);

  return (
    <div>
      <h1>
        The count: <Counter count={count()} />
      </h1>
    </div>
  );
}

function Counter({ count }) {
  return <span>{count}</span>;
}

export default App;

…and the counter no longer works. But why? well it turns out that, when we destructured props in the Counter function call, we called the getter on props.count to assign to a function-scoped count variable. Our function body only runs once, and so therefore our the count we are inserting into the span is only ever 0.

The todo list app

Back to our project at hand, let’s start creating that todo list app. Let’s start bottom-up, making the TodoListItem first. We’ll create a new file next to App.jsx called TodoListItem.jsx:

TodoListItem.jsx

export function TodoListItem(props) {
  return (
    <li
      style={{
        'text-decoration': props.todo.complete ? 'line-through' : undefined,
      }}
    >
      <label>
        <input type="checkbox" checked={props.todo.complete} />
        {props.todo.text}
      </label>
    </li>
  );
}

We see that we condition the “line-through” stype of our list-item based on whether props.todo.complete is true. We additionally set the checked property of the input based on this value. Finally, we display props.todo.text next to he checkbox.

Let’s now import this component into our App and create an initial, two-item array of todos. We’ll manually pass these props to a couple TodoListItem components.

import { createSignal } from 'solid-js';
import { TodoListItem } from './TodoListItem';

function App() {
  const [todos, setTodos] = createSignal([
    { text: 'Walk the dog', complete: false },
    { text: 'Do homework', complete: true },
  ]);

  return (
    <ul>
      <TodoListItem todo={todos()[0]} />
      <TodoListItem todo={todos()[1]} />
    </ul>
  );
}

export default App;

Now if we go to http://localhost:3000, we can see a couple items on our list. Progress!

a couple todo list items

Handling arrays

We can’t manually list items in an array that’s going to change lengths over time. If you’re used to React, you might think we should map over the todos(). This would work, but it’s not idiomatic Solid. This is because the entire array would re-render whenever todos() is updated, but we want (and can get) a much lower level of granularity.

Instead, Solid exports a For component that makes this simple for us:

function App() {
  const [todos, setTodos] = createSignal([
    { text: 'Walk the dog', complete: false },
    { text: 'Do homework', complete: true },
  ]);

  return (
    <ul>
      <For each={todos()}>{(todo) => <TodoListItem todo={todo} />}</For>
    </ul>
  );
}

And now we can iterate over any number of ites and any changes will only cause re-rendering within the affected element. Pretty cool!

Setting state

In our TodoListItem component, we need to be able to toggle the completeness of an item. We can do his by passing setTodos to the TodoListItem component and then calling it in the input onChange handler.

App.jsx

function App() {
  const [todos, setTodos] = createSignal([
    { text: 'Walk the dog', complete: false },
    { text: 'Do homework', complete: true },
  ]);

  return (
    <ul>
      <For each={todos()}>
        {(todo) => <TodoListItem todo={todo} setTodos={setTodos} />}
      </For>
    </ul>
  );
}

TodoListItem.jsx

export function TodoListItem(props) {
  return (
    <li
      style={{
        'text-decoration': props.todo.complete ? 'line-through' : undefined,
      }}
    >
      <label>
        <input
          type="checkbox"
          checked={props.todo.complete}
          onChange={() => {
            props.setTodos((todos) => {
              const newTodos = todos.map((todo) =>
                props.todo === todo
                  ? { ...todo, complete: !todo.complete }
                  : todo
              );
              return newTodos;
            });
          }}
        />
        {props.todo.text}
      </label>
    </li>
  );
}

Note that setTodos can take a callback function, which allows you to access the current todos array and return a new version. In this case, we map over the todos array, flip the complete prop of the correct todo, and then return a new array.

toggling items

Building out the AddTodoForm

The final piece of the puzzle is the AddTodoForm. We’ll need to maintain a local signal to capture the new todo value. We’ll make sure to pass setTodos as a prop so that we can add a new todo when the Add button is clicked.

export function AddTodoForm(props) {
  const [newTodo, setNewTodo] = createSignal('');

  return (
    <form>
      <input
        value={newTodo()}
        onChange={(e) => {
          setNewTodo(e.target.value);
        }}
      />
      <button
        type="submit"
        onClick={(e) => {
          e.preventDefault();
          props.setTodos((todos) => {
            return [...todos, { text: newTodo(), complete: false }];
          });
          setNewTodo('');
        }}
      >
        Add
      </button>
    </form>
  );
}

And we can add this component to our App like so:

App.jsx

import { createSignal, For } from 'solid-js';
import { AddTodoForm } from './AddTodoForm';
import { TodoListItem } from './TodoListItem';

function App() {
  const [todos, setTodos] = createSignal([
    { text: 'Walk the dog', complete: false },
    { text: 'Do homework', complete: true },
  ]);

  return (
    <div>
      <ul>
        <For each={todos()}>
          {(todo) => <TodoListItem todo={todo} setTodos={setTodos} />}
        </For>
      </ul>
      <AddTodoForm setTodos={setTodos} />
    </div>
  );
}

export default App;

Now we navigate over to http://localhost:3000:

finished

And we’re finished! Pretty cool. If you’d like some more practice, consider trying to add a TodoList component so that the App component doesn’t have to see the ul implementation.

Conclusion

I hope you enjoyed diving into this framework with me. If you’re coming from the React world, you probably already see the promise of granular reactivity and not having to declare dependencies. I’m pretty excited to see where this framework goes!

Nick Scialli

Nick Scialli is a senior UI engineer at Microsoft.

© 2024 Nick Scialli