Skip to content

Learning Four Key Primitives In Solid

Posted on:February 19, 2024

Today we are going to talk about four key primitives that Solid utilizes to make building web applications fun for the developer and also great for end users.

They are:

Signals

Where else would one start but with signals. I’m starting to think fine-grained reactivity is Solid’s superpower and is what trickles into everything else mentioned later.

Early on I don’t think I really understood how powerful signals are. My first thought was “I can make useState global!”, but I missed the point of decoupling your data and rendering from the component tree. The granularity of React updates are at the component level while Solid is at the DOM node level.

I find because of this you aren’t forced to make separate components just for the sake of it. A good example of this is to use <Suspense>. In Solid createAsync return a signal that will trigger suspense because of where the value is accessed.

// Solid
export default function Home() {
    const nums = createAsync(() => getNums());

    return (
        <Suspense fallback="Loading...">
          {nums()}
        </Suspense>
    );
}

For React you kind of want to be able to do this:

// React
export default async function Home() {
    const nums = await getNums();

    return (
        <Suspense fallback="Loading...">
          {nums}
        </Suspense>
    );
}

This actually won’t work because the await is outside your suspense. You need to create a nested async component like this:

// React
export default async function Home() {
    return (
        <Suspense fallback="Loading...">
          <AsyncNumsComponent>
        </Suspense>
    );
}

async function AsyncNumsComponent() {
  const nums = await getNums();

  return (
    <>
      {nums}
    </>
  );
}

Non-blocking Async

This also shows the power of a non-blocking async primitive. Using the knowledge from above can tell if the following code would waterfall?

<FileComponent path="./tmp/solidHome.txt">
  <FileComponent path="./tmp/solidHome2.txt">
    <FileComponent path="./tmp/solidHome3.txt" />
    <FileComponent path="./tmp/solidHome4.txt" />
  </FileComponent>
</FileComponent>

This is not so clear. My React brain says yes because of it probably having await in each component, but in Solid’s case it doesn’t because createAsync is non-blocking.

Let’s compare the two:

// React
export async function FileComponent(props) {
    const fileText = await fs.readFileSync(props.path, 'utf-8');

    await new Promise(r => setTimeout(r, 2000));

    return (
        <>
            <pre>{fileText}</pre>
            {props.children}
        </>
    )
}

// Solid
export const readFile = (async (path) => {
    'use server';

    const fileText = fs.readFileSync(path, 'utf-8');

    await new Promise(r => setTimeout(r, 2000));

    return fileText;
};

export function FileComponent(props) {
    const fileText = createAsync(() => readFile(props.path));

    return (
        <>
            <pre>{fileText()}</pre>
            {props.children}
        </>
    )
}

At first glance they look pretty similar. If you compare the network tab you’ll see three requests for Solid where in the React one you only get one. But is the three requests actually worse?

Solid's Network Tab

solid network tab

React's Network Tab

react network tab

The React one takes three times as long, and waterfalls on the server even though it’s one request. The Solid one actually fetches the data in parallel.

Solid’s createAsync is powerful and I’m probably only scratching the surface. Solid can also use this information to selectively fetch only what it needs, or even fetch the data for the next page in a single-flight mutation.

Side Note: To improve the Next version you will want to use the preload pattern and fetch the data in parallel according to this page in the docs.

// React
// FileComponent.tsx

import * as fs from "fs";
import { cache } from "react";

export const readFile = cache(async (path: string) => {
    const fileText = await fs.readFileSync(path, 'utf-8');

    await new Promise(r => setTimeout(r, 2000));
    return fileText;
});

export async function FileComponent(props: { path: string, children?: any }) {
    const fileText = await readFile(props.path);

    return (
        <>
            <pre>{fileText}</pre>
            {props.children}
        </>
    )
}

// page.tsx
import Link from 'next/link'
import { FileComponent, readFile } from './components/FileComponent'

export default function Home() {
  void readFile("./tmp/solidHome.txt");
  void readFile("./tmp/solidHome2.txt");
  void readFile("./tmp/solidHome3.txt");
  void readFile("./tmp/solidHome4.txt");

  return (
    <>
      <h1>Hello World</h1>
      <Link href="/about">About</Link>
      <FileComponent path="./tmp/solidHome.txt">
        <FileComponent path="./tmp/solidHome2.txt">
          <FileComponent path="./tmp/solidHome3.txt" />
          <FileComponent path="./tmp/solidHome4.txt" />
        </FileComponent>
      </FileComponent>
    </>
  )
}

The important bits are wrapping the data function in the cache and then calling the function without await higher up in the tree.

Server Functions

Another primitive we are only now starting to grasp is the use of server functions. Server functions have the advantage of getting type safety across the network similar to tRPC, while also helping avoid creating one off endpoints on your backend.

It is the foundation that loaders, actions, etc are built on. In Remix these are usually always server based except for recent additions of clientLoader and clientAction. Solid Start on the other hand is isomorphic by default and the 'use server' makes it server only.

// Remix (React)
export const loader = async () => {};
export const action = async () => {};

// Next (React)
export async function AsyncComponent() {
  const data = await loader();
}
export const action = async () => {
  "use server";
};

The cool part about server functions is they can call each other. In Remix you can’t call one loader from another but in Solid you could do:

// Solid
export const getUser = async id => {
  "use server";

  return {
    user: await db.findUser(id),
    friends: getFriends(id),
  };
};

export const getFriends = async id => {
  "use server";

  return db.getUserFriends(id);
};

The thing to note here is that on the server during SSR they are just normal function calls, but they are in fact super powered in that they can be called via RPC from the client.

// Solid
export function UserComponent(props) {
    const user = createAsync(() => getUser(props.userId));

    return (
        <>
            <h1>{user.name}</h1>
            <Friends friends={user.friends} />
        </>
    )
}

You can also stream in the data. You will notice the friends doesn’t actually get awaited:

// Solid
return {
  user: await db.findUser(id),
  friends: getFriends(id),
};

This sends the promise to the frontend which can then picked up by createAsync

// Solid
import { createAsync } from "@solidjs/router"
import { For, Suspense } from "solid-js";

export default function Friends(props) {
    const friends = createAsync(() => props.friends);

    return (
        <Suspense fallback="Loading...">
            <ul>
                <For each={friends()}>
                    {(friend) => (
                        <li>{friend.name}</li>
                    )}
                </For>
            </ul>
        </Suspense>
    )
}

To be fair Remix can also stream promises using defer but it requires a bit more ceremony using an Await component.

// Remix (React)
import { Await } from "@remix-run/react";

<Suspense fallback={<div>Loading...</div>}>
  <Await resolve={somePromise}>
    {(resolvedValue) => <p>{resolvedValue}</p>}
  </Await>
</Suspense>;

Actions

You’ve seen how to get data using server functions, but what about mutating data. Solid shines in the flexibility of its actions. A lot of this was originally pioneered by the Remix folks, so you’ll find something similar there.

In Solid Start showing pending UI or the result of the action is this easy.

// Solid
const addNum = action(async () => {
    "use server";

    return 'success';
}, 'addNum');

export default function Home() {
  const addNumAction = useSubmission(addNum);

  return (
    <main>
      <form action={addNum} method='post'>
        <button type="submit">Add Num</button>
      </form>
      <pre>
        {addNumAction.pending ? 'pending' : 'not pending'}
        {addNumAction.result}
      </pre>
    </main>
  );
}

I find two things noteworthy that separate this approach from other frameworks.

You can useSubmission for an action anywhere in your app. Even in a completely different part of the tree. In something like Next.js you are limited to reading the result of the action within the form itself. You also need to separate it into a different nested component so you can use the hook properly.

// React
// FormComponent.tsx
'use client';

import { useFormState } from "react-dom";
import PendingComponent from "./PendingComponent";

export default function FormComponent({ addNum }: any) {
    const [state, formAction] = useFormState(addNum, null);

    return (
        <form action={formAction}>
            <button type="submit">Add Num</button>

            <PendingComponent />
            <p>{state}</p>
        </form>
    )
}

// PendingComponent.tsx
'use client';

import { useFormStatus } from "react-dom";

export default function PendingComponent() {
    const { pending } = useFormStatus();

    return (
        <p>{pending ? 'pending' : 'not pending'}</p>
    );
}

The second thing that separates this approach from Remix is the actions are not all top level at the route. You can call them at the component level as very specific functions. I find in Remix if you have multiple actions you need to do a lot of conditionals.

// Remix (React)
export const action = async ({ request }) => {
  const formData = await request.formData();
  const action = formData.get("action");
  if (action === "add") {
    // add
  } else if(action === 'delete) {
    // delete
  }
};
<Form method="post">
  <input type="hidden" name="action" value="add" />
  <button type="submit">Add</button>
</Form>

<Form method="post">
  <input type="hidden" name="" value="delete" />
  <button type="submit">Delete</button>
</Form>

With Solid Start you just pass the very specific function to the form action.

// Solid
const addNum = action(async () => {
  'use server';
  // add
}, 'add-num);
const deleteNum = action(async () => {
  'use server';
  // delete
}, 'delete-num');
<form action={addNum} method="post">
  <button type="submit">Add</button>
</form>

<form action={deleteNum} method="post">
  <button type="submit">Delete</button>
</form>

Conclusion

These are just a couple of the cool concepts in Solid, but there many more and I’ve listed a few of them below for further reading.