Solid v2 and Firebase

Jeff Huleatt

portrait of Jeff Huleatt

Framework support for Suspense-like async isn’t new (here’s a talk about Suspense I presented in 2019), but for streaming databases like Firestore, it has been tricky to integrate with framework-native async.

That’s why I was so excited to hear about first-class support for streaming data in the Solid v2 release candidate announcement. I built a to-do list app with Firestore and Firebase Auth to find out what it’s like to integrate Firestore’s onSnapshot and Auth’s onAuthStateChanged listeners with Solid v2’s new async model.

Screenshot of the Firestore to-do list app built with Solid v2.

Firebase pushes, Solid pulls

Solid v2’s async reactivity expects streaming data in the form of async iterables. I had trouble wrangling Firestore’s onSnapshot handlers into a Solid-friendly form until I found Repeater, which accurately describes itself as “The missing constructor for creating safe async iterators”.

With Repeater, I built a generic function to convert any Firestore query listener into an AsyncIterable:

Convert onSnapshot to AsyncIterable
export function queryStream<T>(
  query: Query<T>,
): AsyncIterable<Array<FirestoreDoc<T>>> {
  return new Repeater<Array<FirestoreDoc<T>>>(async (push, stop) => {
    const unsubscribe = onSnapshot(
      query,
      { includeMetadataChanges: true },
      (snap) => {
        void push(
          snap.docs.map((d) => ({
            id: d.id,
            ...d.data(),
            // The Firestore SDK automatically performs optimistic updates.
            // This field identifies if a write has persisted, or if it is still pending.
            hasPendingWrites: d.metadata.hasPendingWrites,
          })),
        );
      },
      (err) => {
        stop(err);
      },
    );

    await stop;
    unsubscribe();
  }, new SlidingBuffer(1 /* Don't replay old updates, always render with latest data */));
}

I did similar for Auth:

Convert onAuthStateChanged to AsyncIterable
export function authStream(): AsyncIterable<User | null> {
  return new Repeater<User | null>(
    async (push, stop) => {
      const unsubscribe = onAuthStateChanged(
        auth,
        (user) => {
          void push(user);
        },
        (err) => {
          console.warn('onAuthStateChanged error:', err);
          stop(err);
        }
      );

      await stop;
      unsubscribe();
    },
    new SlidingBuffer(1)
  );
}

Async sources with createMemo and createProjection

To use the async iterable, Solid v2 has two APIs for two different kinds of data: createMemo for immutable, simple values, and createProjection for structured data where individual properties change over time. createMemo was a good fit for Auth state, while createProjection allowed Solid to track changes to individual todos in the Firestore collection.

Firestore with createProjection
const todos = createProjection<Todo[]>(() => {
  if (isServer) return [];
  return queryStream<TodoData>(todosQuery);
}, []);
Auth with createMemo
const user = createMemo(() => {
  if (isServer) return null;
  return authStream();
});

With that, I can consume these async sources in a Solid component, and everything works automatically with Solid’s <Loading> and <Errored> components! No need for tricky loading states.

TodoList component
function TodoList() {
  const [todos, { addTodo, toggleTodo, removeTodo }] = createTodos();

  const handleSubmit = async (e: SubmitEvent) => {
    e.preventDefault();
    const form = e.currentTarget as HTMLFormElement;
    const data = new FormData(form);
    const title = String(data.get("title") || "").trim();
    if (!title) return;
    form.reset();
    await addTodo(title);
  };

  return (
    <section>
      <form onSubmit={handleSubmit} role="group">
        <input
          name="title"
          type="text"
          placeholder="What needs to be done?"
          required
        />
        <button type="submit">Add</button>
      </form>

      <Errored
        fallback={(error, reset) => (
          <article>
            <p>Database error: {String(error())}</p>
            <button type="button" onClick={reset}>
              Retry Connection
            </button>
          </article>
        )}
      >
        <Loading
          fallback={<p aria-busy="true">Connecting to realtime stream...</p>}
        >
          <ul style={{ "list-style": "none", padding: "0" }}>
            <For each={todos}>
              {(todo: Todo) => (
                <TodoItem
                  todo={todo}
                  onToggle={toggleTodo}
                  onRemove={removeTodo}
                />
              )}
            </For>
          </ul>
        </Loading>
      </Errored>
    </section>
  );
}

Preloading data

Solid’s router was also rewritten for v2. Most notably, there is a new route.preload field. This is normally used to block a route from loading until the data required for the page has loaded, but I used it a little differently. Since I want the Firestore SDK, with its built-in caching and realtime streaming, to drive the state, all I do in route.preload is check if the user is signed in, and then warm up the Firestore query without blocking on it.

route.preload
// Preloads todos and redirects early to /login if unauthenticated
export const route = {
  preload: async ({ intent }) => {
    if (isServer) return;

    // Block page render until authentication state is known
    await auth.authStateReady();

    // Redirect to login page right away if user is signed out
    if (!auth.currentUser) {
      // Only redirect if the page is actually being loaded
      if (intent !== "preload") {
        return new Response(null, {
          status: 302,
          headers: { Location: "/login" },
        });
      }
      return;
    }

    // Start warming up the Firestore cache, but don't block page render
    void getDocs(todosQuery);
  },
} satisfies RouteDefinition;

Solid Router also has a new query API for request caching, but that should not be used with Firestore. The Firestore SDK already caches requests (and performs optimistic updates on writes), so caching with query would be a cache on top of a cache that could lead to all sorts of weird edge cases. Remember, there are 3 hard things in software engineering: caching, naming things, and off-by-one errors.

Summary

Solid v2’s native support for async streaming data makes it a great fit for Firebase’s streaming APIs. In the same way that Solid inspired other frameworks like Angular, Svelte, and Preact to implement Signals, I hope other frameworks implement built-in streaming support too.