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 theonSnapshot
event handler to a signal from theuser
signal. createComputed
is unique to SolidJS and solves the reactivity problem witheffect()
.- 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 noFragment
types in Solid for this. - You still need to pass the odd
{(item, index) => ()}
inside aFor
loop. - React uses
className
, but Solid is smarter and uses our friendclass
.
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