Home
> SolidJS Todo App with Firebase
Get updates on future FREE course and blog posts!
Subscribe

SolidJS Todo App with Firebase

21 min read

Jonathan Gamble

jdgamble555 on Wednesday, May 1, 2024 (last modified on Wednesday, May 1, 2024)

I figured SolidJS would be just like React, but it’s not. It’s way better. I had a few learning curves but got my app up and running. My biggest problem is the lack of good documentation for Solid Start techniques, but the framework itself is solid. See what I did there?

TL;DR#

Solid Start is similar to NextJS but works differently under the hood. Using helper functions like Show and For makes JSX more bearable and the underlying signals quick. The loaders require a few extra boilerplate steps, but Suspense can be powerful if you need streaming.

Setup#

First, set up your .env file with the Firebase credentials as usual but with the VITE_ prefix for client recognition.

	VITE_PUBLIC_FIREBASE_CONFIG={"apiKey":"...","authDomain":"..."...}

📝 Make sure your keys have quotes.

Firebase Library#

Create your lib/firebase.ts file, sharing your app for the server version.

	
import { getApp, getApps, initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';

const firebase_config = JSON.parse(
    import.meta.env.VITE_PUBLIC_FIREBASE_CONFIG
);

// initialize and login

export const app = getApps().length
    ? getApp()
    : initializeApp(firebase_config);

export const auth = getAuth(app);
export const db = getFirestore(app);

Shared Context#

I created a universal Shared Context Library for React. Since this uses almost identical context code as React, it was easy to translate. This allows you to have one provider and import your reusable hooks wherever you want.

	import {
    Component,
    JSX,
    createContext,
    useContext,
    type Context
} from "solid-js";

const _Map = <T,>() => new Map<string, T>();
const Context = createContext(_Map());

export const Provider: Component<{ children: JSX.Element }> = ({
    children
}) =>
    <Context.Provider value={_Map()}>{children}</Context.Provider>;

const useContextProvider = <T,>(key: string) => {
    const context = useContext(Context);
    return {
        set value(v: T) { context.set(key, v); },
        get value() {
            if (!context.has(key)) {
                throw Error(`Context key '${key}' Not Found!`);
            }
            return context.get(key) as T;
        }
    }
};

export const useShared = <T, A>(
    key: string,
    fn: (value?: A) => T,
    initialValue?: A
) => {
    const provider = useContextProvider<Context<T>>(key);
    if (initialValue !== undefined) {
        const state = fn(initialValue);
        const Context = createContext<T>(state);
        provider.value = Context;
    }
    return useContext(provider.value);
};

Put the Provider inside the routes/index.ts file so they can be used for our main route. Normally, you would share this everywhere.

	import { Title } from "@solidjs/meta";
import Home from "~/components/home";
import { Provider } from "~/lib/use-shared";

export default function Index() {
  return (
    <main>
      <Provider>
        <Title>SolidStart - Firebase</Title>
        <Home />
      </Provider>
    </main>
  );
}

🖇️ Notice how easy it is to use the Title tag anywhere in your code edit the meta tag.

User Hook#

The signals in Solid work almost identically to useState and useEffect in React; they are just faster and actually use signals. No more useMemo malarky for basic rendering. That being said, you can use createMemo to memoize expensive computations. For this example, we are using createStore, but we could have used createSignal.

	import { useShared } from "./use-shared";
import {
    User,
    onIdTokenChanged,
    signOut,
    signInWithPopup,
    GoogleAuthProvider
} from "firebase/auth";
import { auth } from "./firebase";
import { createStore } from "solid-js/store";
import { onCleanup } from "solid-js";

export interface userData {
    photoURL: string | null;
    uid: string;
    displayName: string | null;
    email: string | null;
};

type UserState = {
    loading: boolean;
    data: userData | null;
};

export function _useUser(initialValue: UserState = {
    loading: true,
    data: null
}) {

    const _store = createStore<UserState>(initialValue);

    const setUser = _store[1];

    setUser(v => ({ ...v, loading: true }));

    // subscribe to user changes
    const unsubscribe = onIdTokenChanged(auth, (_user: User | null) => {

        if (!_user) {
            setUser({ data: null, loading: false });
            return;
        }

        // map data to user data type
        const { photoURL, uid, displayName, email } = _user;
        const data = { photoURL, uid, displayName, email };

        // print data in dev mode
        if (process.env.NODE_ENV === 'development') {
            console.log(data);
        }

        // set store
        setUser({ loading: false, data });
    });

    onCleanup(unsubscribe);

    return _store;
}

export const useUser = (initialValue?: UserState) =>
    useShared('user', _useUser, initialValue);

export const loginWithGoogle = () =>
    signInWithPopup(auth, new GoogleAuthProvider());

export const logout = () => signOut(auth);

📌 User Notice#

  • I’m converting the onIdTokenChanged event handler to a signal.
  • Always unsubscribe. In SolidJS, we use onCleanup.
  • Export the hook with useShared, we only want to subscribe once when we share our user state.

Todos Hook#

Fetching todos is similar but relies on the user signal.

	import {
    type DocumentData,
    onSnapshot,
    type QuerySnapshot,
    Timestamp
} from 'firebase/firestore';
import {
    addDoc,
    collection,
    deleteDoc,
    doc,
    orderBy,
    query,
    serverTimestamp,
    updateDoc,
    where
} from 'firebase/firestore';

import { db } from './firebase';
import { useUser } from './use-user';
import { createStore } from 'solid-js/store';
import { createComputed, onCleanup } from 'solid-js';

export interface TodoItem {
    id: string;
    text: string;
    complete: boolean;
    created: Date;
    uid: string;
};

export const snapToData = (
    q: QuerySnapshot<DocumentData, DocumentData>
) => {

    // creates todo data from snapshot
    if (q.empty) {
        return [];
    }
    return q.docs.map((doc) => {
        const data = doc.data({
            serverTimestamps: 'estimate'
        });
        const created = data.created as Timestamp;
        return {
            ...data,
            created: created.toDate(),
            id: doc.id
        }
    }) as TodoItem[];
}

export function useTodos() {

    const _user = useUser();

    const _store = createStore<{
        todos: TodoItem[],
        loading: boolean
    }>({
        todos: [],
        loading: true
    });

    const user = _user[0];

    const setTodos = _store[1];

    setTodos(v => ({
        ...v,
        loading: true
    }));

    createComputed(() => {

        if (!user.data) {
            setTodos({
                loading: false,
                todos: []
            });
            return _store[0];
        }

        const unsubscribe = onSnapshot(

            // query realtime todo list
            query(
                collection(db, 'todos'),
                where('uid', '==', user.data.uid),
                orderBy('created')
            ), (q) => {

                // get data, map to todo type
                const data = snapToData(q);

                /**
                 * Note: Will get triggered 2x on add 
                 * 1 - for optimistic update
                 * 2 - update real date from server date
                 */

                // print data in dev mode
                if (process.env.NODE_ENV === 'development') {
                    console.log(data);
                }

                // add to store
                setTodos({
                    loading: false,
                    todos: data
                });

            });

        onCleanup(unsubscribe);
    });

    return _store[0];
};

export const addTodo = (
    e: SubmitEvent,
    uid: string
) => {

    e.preventDefault();

    // get and reset form
    const target = e.target as HTMLFormElement;
    const form = new FormData(target);
    const { task } = Object.fromEntries(form);

    if (typeof task !== 'string') {
        return;
    }

    // reset form
    target.reset();

    addDoc(collection(db, 'todos'), {
        uid,
        text: task,
        complete: false,
        created: serverTimestamp()
    });
}

export const updateTodo = (id: string, complete: boolean) => {
    updateDoc(doc(db, 'todos', id), { complete });
}

export const deleteTodo = (id: string) => {
    deleteDoc(doc(db, 'todos', id));
}

📌 Todos Notice#

  • I’m using createComputed to derive the onSnapshot event handler to a signal from the user signal.
  • createComputed is unique to SolidJS and solves the reactivity problem with effect().
  • Again, always unsubscribe. In SolidJS, we use onCleanup.
  • We don’t need useShared since this is only called once in one component.
  • The snapToData converts the collection array of metadata, to actual data. See Data Patterns.

Profile Component#

The first big change you will notice with Solid is the Show component. It makes handling JSX much easier. However, when dealing with a possible null or undefined check, you have to wrap the internal child component inside this awkward {() => ( ... )}. I understand this is necessary for proper reactive handling, but it is really a hack for JSX’s shortcomings. Either way, it is better than React.

	import { Logout } from "~/lib/helpers";
import { useUser } from "~/lib/use-user";
import Todos from "./todos";
import { Show } from "solid-js";

export default function Profile() {

    const [user] = useUser();

    return (
        <Show when={user.data}>
            {(user) => (
                <div class="flex flex-col gap-3 items-center">
                    <h3 class="font-bold">Hi {user().displayName}!</h3>
                    <Show when={user().photoURL}>
                        {(data) => (
                            <img src={data()} width="100" height="100" alt="user avatar" />
                        )}
                    </Show>
                    <p>Your userID is {user().uid}</p>
                    <Logout />
                    <Todos />
                </div>
            )}
        </Show>
    );
}

Todos Component#

The todos require a similar pattern. I must check for the user again since I use a shared hook. If you deal with prop-drilling, you won’t have this problem, but you will have to deal with prop-drilling.

	import { addTodo, useTodos } from "~/lib/use-todos";
import { useUser, userData } from "~/lib/use-user";
import { Todo } from "./todo-item";
import { For, Show } from "solid-js";

export default function Todos() {

    const _user = useUser();

    const todoStore = useTodos();

    const user = _user[0];

    return (
        <Show when={user.data}>
            {(user) => (
                <>
                    <div class="grid grid-cols-[auto,auto,auto,auto] gap-3 justify-items-start">
                        <For each={todoStore.todos} fallback={<p><b>Add your first todo item!</b></p>}>
                            {(todo, _index) => (
                                <Todo {...{ todo }} />
                            )}
                        </For>
                    </div>
                    <TodoForm {...user()} />
                </>
            )}
        </Show>
    );
}

export const TodoForm = (user: userData) => {
    return (
        <form class="flex gap-3 items-center justify-center mt-5" onSubmit={(e) => addTodo(e, user.uid)}>
            <input class="border p-2" name="task" />
            <button class="border p-2 rounded-md text-white bg-sky-700" type="submit">
                Add Task
            </button>
        </form>
    );
};

  • The fallback is a simple way to handle the loading state of an empty array.
  • The items are tracked automatically, so you don’t need a key. There are no Fragment types in Solid for this.
  • You still need to pass the odd {(item, index) => ()} inside a For loop.
  • React uses className, but Solid is smarter and uses our friend class.

Todo Item#

The individual todos are cleaner with Show than with ternaries.

	import { Show } from "solid-js";
import { TodoItem, deleteTodo, updateTodo } from "~/lib/use-todos";

// each todo item
export const Todo = ({ todo }: { todo: TodoItem }) => {
    return (
        <>
            <span class={todo.complete ? 'line-through text-green-700' : ''}>
                {todo.text}
            </span>
            <span class={todo.complete ? 'line-through text-green-700' : ''}>
                {todo.id}
            </span>
            <Show when={todo.complete}>
                <button type="button" onClick={() => updateTodo(todo.id, !todo.complete)}>
                    ✔️
                </button>
            </Show>
            <Show when={!todo.complete}>
                <button type="button" onClick={() => updateTodo(todo.id, !todo.complete)}>
                    ❌
                </button>
            </Show>
            <button type="button" onClick={() => deleteTodo(todo.id)}>🗑</button>
        </>
    );
};

🖇️ I didn’t need the JSX {() => ()} because I wasn’t dealing with null or undefined inside the Show component.

Home#

Finally, we can go home. This is the first component that gets rendered, but I saved it for last.

	import { Loading, Login } from "~/lib/helpers";
import { useUser } from "~/lib/use-user";
import Profile from "./profile";
import { Match, Switch } from "solid-js";

export default function Home() {

    const [user] = useUser({ data: null, loading: true });

    return (
        <div class="text-center">
            <h1 class="text-3xl font-semibold my-3">
                Solid Start Firebase Todo App
            </h1>
            <Switch fallback={<Login />}>
                <Match when={user.loading}>
                    <Loading />
                </Match>
                <Match when={user.data}>
                    <Profile />
                </Match>
            </Switch>
        </div>
    );
}

We set the user for the first time before onAuthIdTokenChanged. I also used Switch and Match to show you other possibilities as well.

About Page#

First, create an about library at lib/about.ts that uses Firestore Lite if you want to host on the Edge.

	import { doc, getDoc, getFirestore } from "firebase/firestore/lite";
import { app } from "./firebase";

const db = getFirestore(app);

type AboutDoc = {
    name: string;
    description: string;
};

export const getAbout = async () => {

    const aboutSnap = await getDoc(
        doc(db, '/about/ZlNJrKd6LcATycPRmBPA')
    );

    if (!aboutSnap.exists()) {
        throw 'Document does not exist!';
    }

    return aboutSnap.data() as AboutDoc;
};

About Route#

Loading the data from the server required a lot of extra boilerplate compared to NextJS, Qwik, or Remix. There were no good, complete examples, so I had to hit up Discord to figure this out.

	import { Title } from "@solidjs/meta";
import { RouteDefinition, cache, createAsync } from "@solidjs/router";
import { Show } from "solid-js";
import { getAbout } from "~/lib/about";

// load data once and cache it
const getAboutPage = cache(async () => {
  'use server';
  return await getAbout();
}, 'about');

// preload the data
export const route = {
  load: () => getAboutPage(),
} satisfies RouteDefinition;

export default function About() {

  // get your preloaded data
  const about = createAsync(() => getAboutPage(), { deferStream: true });

  return (
    <Show when={about()}>
      {(data) => (
        <>
          <Title>About</Title>
          <div class="flex items-center justify-center my-5">
            <div class="border w-[400px] p-5 flex flex-col gap-3">
              <h1 class="text-3xl font-semibold">{data().name}</h1>
              <p>{data().description}</p>
            </div>
          </div>
        </>
      )}
    </Show>
  );
};

Solid Start uses Suspense by default to stream your data to the client as it becomes available. This is awesome, but it gives a bad user experience for this particular example. I don’t want the page to ever be blank. Waiting those few extra milliseconds is worth the wait. Adding deferStream accomplished this.

Final Thoughts#

I hate JSX, but SolidJS makes it a bit more bearable if you learn its patterns. While the documentation needs more clear examples, building with it was fun.

Demo: Vercel Edge

Repo: GitHub


Related Posts

© 2024 Code.Build