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

NextJS Todo App with Firebase

21 min read

Jonathan Gamble

jdgamble555 on Wednesday, March 13, 2024 (last modified on Tuesday, April 23, 2024)

After building my Firebase Todo App in Qwik, SvelteKit, and Angular, I realized the most popular Framework has still been left behind. I must admit I am not a huge React fan, but I still write articles about it due to the overwhelmingly bad design patterns you see everywhere. This version is very close to the Qwik Version, but with a few changes. I prefer Qwik because it is awesome, but more people are still enquiring on how to build in NextJS.

TL;DR#

This Todo app version uses the NextJS client components to connect to Firebase. However, you can see that using a Server Component for fetching is still possible as long as you don’t need Authentication for the documents you are parsing. I use useState here to create custom hooks for todos and user state. This can be easily deployed to any Edge Function.

Firebase Setup#

Setting up Firebase is exactly the same as every other server Framework. Angular is the only exception because it uses ZoneJS and has an official package.

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

const firebase_config = JSON.parse(
  process.env.NEXT_PUBLIC_FIREBASE_CONFIG as string
);

// initialize and login

if (!getApps().length) {
    initializeApp(firebase_config);
}

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

We have to prefix our name to our env variable with NEXT_PUBLIC_. We store it in our .env file and we must add double quotation marks to the keys.

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

User Hook#

I made a custom user hook to track the state of the user’s login. Other frameworks may use stores or signals, but React has hooks.

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

    const _store = useState<UserState>(initialValue);
    const setUser = _store[1];

    useEffect(() => {

        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]);

    return _store;
}

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

Here you create a user state with useState. This holds the loading and user data. You could create two different hooks for this, but since the states will usually be updated at the same time, one usState hook works fine. You then subscribe to the onIdTokenChanged Firebase observable inside of useEffect in order to track the user login. Only pass the setUser inside the dependency array, as that never changes. If you pass the user data to it, you could get an infinite loop. We update the user state based on the observable, but we only save the data we need. I also print the full data to the console in development mode.

Use Shared Hook#

I created a custom reusable use-shared.ts hook. I’m not going to get into the details here, but just know it allows you to share as many states as you want with only one provider. It stores all contexts in a map to simplify your app. You can read about it in my article Easy Shared Reactive State in React. What you need to know, is that without using it, you would be creating 3 separate subscriptions to the onIdTokenChanged observable, which is more than you need and wastes memory. For the sake of completing, here is the code.

	'use client';

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

We also add our universal provider to our root page.tsx file.

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

const HomePage = () => {
    return (
        <Provider>
            <Home />
        </Provider>
    );
};

export default HomePage;

Todo Hook#

Just like in my other versions of this, you need to get the user in order to get the todos. The Firebase subscription requires the user id.

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

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

        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]);

    return _store[0];
};

This hook is very similar to the user hook, but we require the user when we initiate it. We subscribe to the realtime updates with onSnaphot, and only get the tasks created by our user. We don’t need to share this hook, as we will only call it in the todo component. We always put this in a separate file due to the Single Responsibility Principle.

	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();
        return {
            ...data,
            created: new Date(data.created?.toMillis()),
            id: doc.id
        }
    }) as TodoItem[];
}

Here we declare our Todo type and handle the document meta data (snapshot) to data conversion.

Modifying Data#

For the other CRUD actions, we can easily use the sharable code.

	export const addTodo = (
    e: FormEvent<HTMLFormElement>,
    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));
}

This is standard Firebase handling here. Notice we use the FormEvent<HTMLFormElement> instead of Submit event. This is a specific type to React that helps us with the submit form event.

Home Component#

Our first component just sets the user state up to be reused, and checks for login.

	'use client';

import { Loading, Login } from "@/lib/helpers";
import { useUser } from "@/lib/use-user";
import Profile from "./profile";

export default function Home() {

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

    return (
        <div className="text-center">
            <h1 className="text-3xl font-semibold my-3">
                NextJS Firebase Todo App
            </h1>
            {user.loading ? <Loading /> : user.data ? <Profile /> : <Login />}
        </div>
    );
}

We make this a client component at the top, and all children will be clients automatically.

Profile Component#

Our profile component uses the user and displays it with the todos. We return nothing if there is not user, but technically there should never be a case where this component is displayed when this isn’t a user. This does shut up TypeScript though, and is good practice to handle all possible situations.

	import { Logout } from "@/lib/helpers";
import { useUser } from "@/lib/use-user";
import Image from 'next/image'
import Todos from "./todos";

export default function Profile() {

    const [user] = useUser();

    if (!user.data) {
        return;
    }

    const { displayName, photoURL, uid } = user.data;

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

Todos Component#

	import { addTodo, useTodos } from "@/lib/use-todos";
import { useUser, userData } from "@/lib/use-user";
import { Todo } from "./todo-item";

export default function Todos() {

    const _user = useUser();

    const { todos } = useTodos(_user);

    const user = _user[0];

    if (!user.data) {
        return;
    }

    return (
        <div>
            <div className="grid grid-cols-[auto,auto,auto,auto] gap-3 justify-items-start">
                {todos.length
                    ? todos.map((todo) => <Todo key={todo.id} {...{ todo }} />)
                    : <p><b>Add your first todo item!</b></p>
                }
            </div>
            <TodoForm {...user.data} />
        </div>
    );

}

The todos hook requires a user from the custom user hook, then we just print them in a grid. Some other versions of this may use a table, but I prefer the grid since Tailwind makes this easy. Make sure to keep track of the key in your todo item.

Todo Item Component#

Here we keep track of the individual key (for rendering purposes) with a Fragment. Everything else is just a button that does what is expected.

	import { TodoItem, deleteTodo, updateTodo } from "@/lib/use-todos";
import { Fragment } from 'react';

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

Helpers#

Don’t forget about our helper components.

	import { loginWithGoogle, logout } from "./use-user";

export const Loading = () => {
    return <p>Loading...</p>;
};

export const Login = () => {
    return <button type="button" className="border p-2 rounded-md text-white bg-red-600" onClick={() => loginWithGoogle()}>
        Signin with Google
    </button>
};

export const Logout = () => {
    return <p>
        <button type="button" className="border p-2 rounded-md text-white bg-lime-600" onClick={() => logout()}>
            Logout
        </button>
    </p>;
};

Nothing really groundbreaking here. I just like to organize things simply.

About Page#

While that is gist of the app, I also wanted to show you the case where you can fetch Firebase from the server. While Qwik and SvelteKit require separate processes for this, Server Components make this extremely simple.

	import { getAbout } from "@/lib/about";

export default async function AboutPage() {

    const about = await getAbout();

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

Because this is not called inside the default page component, this defaults to a server component. Notice I can call the about function inside the component itself. This can only be done because the entire component is on the server. RSC is wonderful and I hope SvelteKit and others follow. That being said, Qwik doesn’t need it.

Reusable About Function#

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

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

I use this same code in my other versions.

Firebase Admin#

If you want to use Firebase Admin to grab the data, you will only be able to deploy to NodeJS or a serverless environment, not Edge environments. You should not need this as client side makes sense for authenticated content. However, you can see my SvelteKit Firebase Admin version on an idea of how to do this.

Demo: Vercel Edge

Repo: GitHub

Note: I had to add the ./lib directory to my tailwind.config.ts file in my app.

J


Related Posts

© 2024 Code.Build