Search code examples
reactjsnext.jsapp-router

Next.js App Directory - Update Client Component upon completion of async function. Component only updates upon page refresh


Using Next.js (14.1.0, app router, no SRC folder). I need a client component that itself lives in a globally present menu to update somehow upon the user logging in, however it will only do so when the browser is refreshed. I've tried various implementations of state, context, and useEffect to no avail. I'm not sure how to explain this without posting a ton of code snippets so please pardon the length of this. Here's the details.

I have a boilerplate I created that has a simple navigation menu to corresponding routes. The menu is created in a Header component in layout.tsx as it's meant to be present on all pages.

Layout contains <Header>, which contains a <NavWithMobile>, which contains a <AuthButton>

The AuthButton conditionally renders itself depending on the presence of a Supabase user, and is either a login button that routes to /login, or a logOut button that calls a Supabase signOut function from a use server designated file.

The check for a user in AuthButton happens inside a useEffect hook to allow the use of await in a client component. Also in the useEffect hook, a context is set. Layout is wrapped in the context.

Logging in and out works, however, the AuthButton component does not update its state unless the browser is refreshed. The fact that AuthButton does update its state after a browser refresh indicates the context is successfully consumed.

All Supabase async functions, (signin, signout) live in a use server designated separate file so they can be called from a client component.

It's built of the following bits:

layout.tsx

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Header from "@/components/Header";
import { UserContextWrapper } from '@/context/user';

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Texans Giving Back",
  description: "Non Profit Dontations App",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <UserContextWrapper>
          <Header />
          <main className="flex min-h-screen flex-col items-center justify-center">
            {children}
          </main>
        </UserContextWrapper>
      </body>
    </html>
  );
}

Header.tsx

import React from 'react';
import NavWithMobile from "../components/NavWithMobile";

const Header: React.FC = () => {

  return (
    <header>
      <NavWithMobile />
    </header>
  );
};

export default Header;

NavWithMobile.tsx

"use client";

import React, { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import DottedNavDivider from "./DottedNavDivider";
import HamburgerMenuBtn from "./HamburgerMenuBtn";
import CloseBtnX from "./CloseBtnX";
import AuthButton from "@/components/AuthButton";

const NavWithMobile: React.FC<Props> = () => {

  const pathname = usePathname();
  const [menuIsOpen, setMenuIsOpen] = useState(false);

  const toggleMenu = () => {
    setMenuIsOpen(prev => !prev);
  }

  return (
    <>
      <nav className="relative px-4 py-4 flex justify-end items-center bg-white lg:py-6">
        <div className={`${menuIsOpen ? 'hidden' : 'block'} lg:hidden`} onClick={toggleMenu}>
          <button className="navbar-burger flex items-center text-blue-600 ">
            <HamburgerMenuBtn cn="block h-4 w-4 fill-current" vb="0 0 20 20" />
          </button>
        </div>

        <ul className="hidden absolute whitespace-nowrap top-1/2 left-1/2 transform -translate-y-1/2 -translate-x-1/2 lg:flex lg:mx-auto lg:items-center lg:w-auto lg:space-x-6 lg:pr-4">
          <li>
            <Link className={`${pathname == '/' ? 'active' : ''} text-sm text-gray-400 hover:text-gray-500`} href="/">Home</Link>
          </li>
          <li className="text-gray-300">
            <DottedNavDivider cn="w-4 h-4 current-fill" vb="0 0 24 24" />
          </li>
          <li>
            <Link className={`${pathname == '/about' ? 'active' : ''} text-sm text-gray-400 whitespace-nowrap`} href="/about-me">About Me</Link>
          </li>
          <li className="text-gray-300">
            <DottedNavDivider cn="w-4 h-4 current-fill" vb="0 0 24 24" />
          </li>
          <li>
            <Link className={`${pathname == '/services' ? 'active' : ''} text-sm text-gray-400 hover:text-gray-500`} href="/about-site">About This Site</Link>
          </li>
          <li className="text-gray-300">
            <DottedNavDivider cn="w-4 h-4 current-fill" vb="0 0 24 24" />
          </li>
          <li>
            <Link className={`${pathname == '/pricing' ? 'active' : ''} text-sm text-gray-400 hover:text-gray-500`} href="/portfolio">Portfolio</Link>
          </li>
          <li className="text-gray-300">
            <DottedNavDivider cn="w-4 h-4 current-fill" vb="0 0 24 24" />
          </li>
          <li>
            <Link className={`${pathname == '/contact' ? 'active' : ''} text-sm text-gray-400 hover:text-gray-500`} href="/contact">Get in Touch</Link>
          </li>
        </ul>
        <AuthButton />
      </nav>

      <div className={`${menuIsOpen ? 'block' : 'hidden'} navbar-menu relative z-50`} >
        <div className="navbar-backdrop fixed inset-0 bg-gray-800 opacity-25"></div>
        <nav className="fixed top-0 left-0 bottom-0 flex flex-col w-5/6 max-w-sm py-6 px-6 bg-white border-r overflow-y-auto">
          <div className="flex items-center mb-8">
            <button className="navbar-close" onClick={toggleMenu}>
              <CloseBtnX cn={'h-6 w-6 text-gray-400 cursor-pointer hover:text-gray-500'} vb='0 0 24 24' />
            </button>
          </div>
          <div onClick={toggleMenu}>
            <ul>
              <li className="mb-1">
                <Link className={`${pathname == '/' ? 'active' : ''} block p-4 text-sm font-semibold text-gray-400 hover:bg-blue-50 hover:text-blue-600 rounded`} href="/">Home</Link>
              </li>
              <li className="mb-1">
                <Link className={`${pathname == '/about' ? 'active' : ''} block p-4 text-sm font-semibold text-gray-400 hover:bg-blue-50 hover:text-blue-600 rounded`} href="/about-me">About Me</Link>
              </li>
              <li className="mb-1">
                <Link className={`${pathname == '/services' ? 'active' : ''} block p-4 text-sm font-semibold text-gray-400 hover:bg-blue-50 hover:text-blue-600 rounded`} href="/about-site">About This Site</Link>
              </li>
              <li className="mb-1">
                <Link className={`${pathname == '/pricing' ? 'active' : ''} block p-4 text-sm font-semibold text-gray-400 hover:bg-blue-50 hover:text-blue-600 rounded`} href="/portfolio">Portfolio</Link>
              </li>
              <li className="mb-1">
                <Link className={`${pathname == '/contact' ? 'active' : ''} block p-4 text-sm font-semibold text-gray-400 hover:bg-blue-50 hover:text-blue-600 rounded`} href="/contact">Get in Touch</Link>
              </li>
            </ul>
          </div>
          <div className="mt-auto">
            <div className="pt-6">
              <AuthButton />
            </div>
          </div>
        </nav>
      </div>
    </>
  );
};

export default NavWithMobile;

AuthButton.tsx

"use client";

import { signOut, getUser } from "@/utils/supabase/supabaseAuth";
import Link from "next/link";
import { useEffect, useState } from 'react';
import { useUserContext } from '@/context/user';

export default function AuthButton() {
  const { userContext, setUserContext } = useUserContext();

  useEffect(() => {
    const init = async () => {
      const _user = await getUser();
      setUserContext(_user);
    };
    init();
  }, []);

  return userContext ? (
    <div className="flex items-center gap-4">
      {userContext.email}
      <form action={signOut}>
        <button className="py-2 px-4 rounded-md no-underline bg-btn-background hover:bg-btn-background-hover">
          Logout
        </button>
      </form>
    </div>
  ) : (
    <Link
      href="/login"
      className="py-2 px-3 flex rounded-md no-underline bg-btn-background hover:bg-btn-background-hover"
    >
      Login
    </Link>
  );
}

/login/page.tsx

import React from 'react';
import UserAuthForm from "@/components/UserAuthForm";

const Login: React.FC = () => {

  return (
    <>
      <div className="min-h-screen p-6 bg-gray-100 flex justify-center">
        <div className="container max-w-screen-lg mx-auto">
          <div>
            <div className="bg-white rounded shadow-lg p-4 px-4 md:p-8 mb-6">

              <UserAuthForm />
              
            </div>
          </div>
        </div>
      </div>
    </>
  );
};

export default Login;

UserAuthForm.tsx

"use client";

import { useRouter } from 'next/navigation';
import Link from "next/link";
import { headers } from "next/headers";
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
import { SubmitButton } from "@/components/submit-button";
import { signIn, signUp } from "@/utils/supabase/supabaseAuth";

export default function UserAuthForm({
  searchParams,
}: {
  searchParams: { message: string };
}) {

  const router = useRouter();

  return (
    <>
      <div className="grid gap-y-2 text-sm md:grid-cols-5">
        <form className="animate-in flex-1 flex flex-col w-full justify-center gap-2 text-foreground">
          <label className="text-md" htmlFor="email">
            Email
          </label>
          <input
            className="rounded-md px-4 py-2 bg-inherit border mb-6"
            name="email"
            placeholder="[email protected]"
            required
          />
          <label className="text-md" htmlFor="password">
            Password
          </label>
          <input
            className="rounded-md px-4 py-2 bg-inherit border mb-6"
            type="password"
            name="password"
            placeholder="••••••••"
            required
          />
          <SubmitButton
            formAction={signIn}
            className="bg-green-700 rounded-md px-4 py-2 text-foreground mb-2"
            pendingText="Signing In..."
          >
            Sign In
          </SubmitButton>
          <SubmitButton
            formAction={signUp}
            className="border border-foreground/20 rounded-md px-4 py-2 text-foreground mb-2"
            pendingText="Signing Up..."
          >
            Sign Up
          </SubmitButton>
          {searchParams?.message && (
            <p className="mt-4 p-4 bg-foreground/10 text-foreground text-center">
              {searchParams.message}
            </p>
          )}
        </form>
      </div>
    </>
  );
};

I think that's all the relevant parts. I need to have <AuthButton> update its state somehow when the user object is successfully received from Supabase, not just when the page is refreshed. I've tried everything on the entire internet. I just can't see how to put this all together in a way where disconnected components can update after login or logout.


Solution

  • I ended up figuring it out, so here's the answer for anyone else stuck on the same type of problem. The solution was a little bit fragmented.

    First, regarding my original post, I forgot to include a pretty important part, my file containing the Supabase server functions my code referenced. You can see it referenced in the imports, for example:

    import { signOut, getUser } from "@/utils/supabase/supabaseAuth";
    

    supabaseAuth is a "use server" designated file and just a collection of calls to Superbase methods that return a promise. Here's the signin function referenced in the above import:

    export const signIn = async (formData: FormData) => {
    
      const email = formData.get('email') as string;
      const password = formData.get('password') as string;
      const supabase = createClient();
    
      const { data, error } = await supabase.auth.signInWithPassword({
        email,
        password,
      });
    
      return error ? JSON.parse(JSON.stringify(error)) : data;
    };
    

    In my original post I was calling those in two ways, both of which were wrong. The first was from within a form action attribute like so:

    <SubmitButton
        formAction={signUp}
        className="border border-foreground/20 rounded-md px-4 py-2 text-foreground mb-2"
        pendingText="Signing Up..."
      >
    

    and the other was within the useEffect hook of the AuthButton component like so:

    useEffect(() => {
        const init = async () => {
          const _user = await getUser();
          setUserContext(_user);
        };
        init();
      }, []);
    

    That useEffect hook above is almost set up correctly but requires a few changes.

    It's important to note here that everything in my original post regarding how the context was implemented was correct and no changes need to be made with that part.

    The solution is two parts:

    1. Set up the check for session functionality in the useEffect hook correctly, AND put it in the right place. The point of doing this in the useEffect hook is so that when the user manually reloads the page, (on a browser refresh for example), the session context is reset. The correct place to put it for my use case was inside the NavWithMobile component because no matter which page the user happens to be on, the useEffect hook will get called upon a refresh:

       import React, { FC, useEffect } from 'react';
       import { useUserContext } from '@/context/user';
       import { getSession } from "@/utils/supabase/supabaseAuth";
      
       const NavWithMobile: FC = () => {
      
           const { setUserContext } = useUserContext();
      
           useEffect(() => {
             const data = getSession();
      
             data.then(data => {
               if(data.message) {
                 // handle error, show toast etc
               } else {
                 data.session && setUserContext(data.session.user);
               }
             });
           }, []);
      
           return (...
      

    The trick in all cases where I was using the functions in supabaseAuth was the .then() statement, which held up the context being set until the promise resolved. Turns out that there's a tricky subtlety when using await without .then() in a function that wraps another function that returns a promise. I'm not even sure I've fully wrapped my head around it. I just know that using .then() solved it.

    1. In UserAuthForm, instead of calling signin directly within the form action, I needed to wrap signin with a function that could handle the promise just like I did in the useEffect hook above, using .then(), and then call that function from the form action instead:

       const onSignIn = (formData: FormData) => {
         const data = signIn(formData);
      
         data.then(data => {
           if(data.user) {
             setUserContext(data.user);
             router.push('/dashboard');
           } else {
             // handle error, show toast etc
           }
         });
       }
      

    Applying those changes above allowed the session context to be set upon the user logging in, and also allowed a current session context to be reset upon a browser refresh no matter which page the user happened to be on. And like magic, the AuthButton component will rerender itself because its conditional render depends on the state of the session context!

    Hope all that made enough sense!