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

Remix Todo App with Firebase

17 min read

Jonathan Gamble

jdgamble555 on Wednesday, April 24, 2024 (last modified on Monday, May 6, 2024)

After creating my Todo App multiple times for other Frameworks, I figured creating it for Remix would be easy. After all, I had already built the components in React for NextJS. I was wrong. Remix has its own eccentricities.

TL;DR#

Remix requires a few hacks to get the environment variables to work, but it is not too different from NextJS. Loaders are beautiful, and the idea has been copied into other Frameworks like Qwik and SolidJS. This article shows the pitfalls and configuration settings for a good Remix app with Firebase.

Firebase Config#

First, put your Firebase configuration data inside the .env file as usual.

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

📝 Make sure your keys have quotes.

Using Environmental Variables#

Remix does not make this easy, as this was by far one of my biggest hurdles. In your root.ts file, create a loader for your env file.

	export async function loader() {
  return json({
    ENV: {
      PUBLIC_FIREBASE_CONFIG: process.env.PUBLIC_FIREBASE_CONFIG
    },
  });
}

Next, you have to save it to the Window object. This is not my recommended code, but the recommended way by Remix. This should be placed in your Layout component.

	<script
  dangerouslySetInnerHTML={{
    __html: `window.ENV = ${JSON.stringify(
      data.ENV
    )}`,
  }}
/>
<Scripts />

Accessing the Variable#

I created a utils.ts file to get the Firebase variable.

	function isBrowser() {
    return typeof window !== 'undefined';
}

export function getFirebaseConfig() {
    const env = isBrowser()
        ? window.ENV
        : process.env;
    return JSON.parse(env.PUBLIC_FIREBASE_CONFIG);
}

It needs to be available in any environment.

📝 Interestingly enough, the process.env.NODE_ENV works everywhere as expected.

Types#

I also found it useful to create a app/global.d.ts file for storing the config types. You need to export something for it to show up throughout your app.

	declare global {
    interface Window {
        ENV: {
            PUBLIC_FIREBASE_CONFIG: string;
        }
    }
    namespace NodeJS {
        interface ProcessEnv {
            PUBLIC_FIREBASE_CONFIG: string;
        }
    }
}

export { }

Setup#

I created a reusable Firebase hook. You don’t have to do this; you can export and import the variables wherever you want. Firebase is a singleton by default, so you will always call the same instance. However, it is good practice to do this as most classes won’t use singletons.

	import { getApp, getApps, initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import { useShared } from './use-shared';
import { getFirebaseConfig } from './utils';

const firebase_config = getFirebaseConfig();

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

const _useFirebase = () => ({
    auth: getAuth(),
    db: getFirestore()
});

export const useFirebase = (init?: boolean) =>
    useShared('firebase', _useFirebase, init);

We could have put the initializeApp in the use firebase hook. However, it must be later exported and reused in the server About component.

Shared Component#

Notice I also use my reusable single provider useShared. We only need to call useFirebase once. We need access to them everywhere. This uses contexts under the hood in my use-shared-tsx file.

	import {
    FC,
    ReactNode,
    createContext,
    useContext,
    type Context
} from "react";

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

export const Provider: FC<{ children: ReactNode }> = ({ 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);
};

You also need to configure the provider in your entry.client.ts file.

	startTransition(() => {
  hydrateRoot(
    document,
    <Provider>
      <StrictMode>
        <RemixBrowser />
      </StrictMode>
    </Provider>
  );
});

I talk more about my universal provider in my dev.to article.

User Hook#

My user hook is exactly the same as my NextJS Todo App, except you need to import the Firebase hook for the auth and add it to the useEffect dependency array.

	import { useEffect, useState } from "react";
import { useShared } from "./use-shared";
import {
    User,
    onIdTokenChanged,
    signOut,
    signInWithPopup,
    GoogleAuthProvider,
    Auth
} from "firebase/auth";
import { useFirebase } from "./firebase";

export type 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 { auth } = useFirebase();

    const _store = useState<UserState>(initialValue);

    const setUser = _store[1];

    useEffect(() => {

        if (!auth) {
            return;
        }

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

        // subscribe to user changes
        return 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 });
        });

    }, [setUser, auth]);

    return _store;
}

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

export const loginWithGoogle = (auth: Auth | null) => {
    if (!auth) {
        return;
    }
    signInWithPopup(auth, new GoogleAuthProvider());
}

export const logout = (auth: Auth | null) => {
    if (!auth) {
        return;
    }
    signOut(auth);
};

Unfortunately, the same goes for the functions. You have to pass the auth to your functions. You cannot use a custom hook inside a function in React, only inside another custom hook.

	export const Login = () => {
    const { auth } = useFirebase();
    return <button type="button" className="..."
        onClick={() => loginWithGoogle(auth)}>
        Signin with Google
    </button>
};

The same goes for the logout.

Todos Hook#

The todos work similarly, except you must import the db hook.

	 export function useTodos(
    _user: ReturnType<typeof useUser>
) {

    const { db } = useFirebase();

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

    const user = _user[0];

    const setTodos = _store[1];

    useEffect(() => {

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

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

        if (!db) {
            return;
        }

        return 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 });

            });

    }, [setTodos, user.data, db]);

    return _store[0];
}

User and Todos Component#

The rest of the app is exactly the same as my NextJS Todo App with Firebase. Read that article for more in-depth explanations of the React part.

About Page#

My About page shows one document from the server.

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

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

const db = getFirestore(app);

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;
};

Notice I import the app only from the firebase.ts file. This is for a specific reason. If we want to use Remix on a Cloudflare or Vercel Edge Function, we must use the Firestore Lite package.

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

This will call the Firebase Rest API instead of using gRPC functions. Cloudflare only has certain NodeJS packages available and does not support Firebase on the server; this is the one exception.

☢️ You must add the native fetch option to your vite.config.ts in order for Firestore Lite to work.

	installGlobals({ nativeFetch: true });

Loader#

To load the About page on your route, create a routes/about.tsx file.

	import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { getAbout } from "~/lib/about.server";

export const loader = async () => {
    
    const about = await getAbout();
    
    return json(about);
};

export default function AboutPage() {

    const about = useLoaderData<typeof loader>();

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

Remix is known for its easy-of-use loader. When you see server$ in other frameworks, they copied Remix. Import the useLoaderData hook, and it loads your data as expected.

Bugs#

I had some other issues besides the pain of having to manually check for a window object, the terrible environment variable techniques, or the lack of server components; Nuxt had server components before Remix. However, these were not Remix's fault.

Rollup#

There was a bug in Rollup. I had to override to the fixed version. This is not a Remix problem but Vite’s problem.

	"overrides": {
  "rollup": "4.15.0"
}

I have to add Remix was very quick to respond to the bug and give me a solution.

Remix Thoughts#

This was my first experience with Remix, and I had some issues. However, if you’re used to NextJS before Server Components, you might like Remix. If I had to pick, I would go with NextJS, but I know they plan on adding Server Components in the future. I think a lot of my bad experience was a fluke, but time will tell.

I want to build Firebase apps for everyone in all flavors.

Demo: Vercel Serverless
Repo: GitHub

J


Related Posts

© 2024 Code.Build