clouddatabasereal-time

SERIES: Let's build SupaAuth #3

Re-factor for good!

Aftab Alam

Aftab AlamApril 14, 2021

9 min read–––

Aftab Alam

You're on part 3 of the 6-part series Let's build SupaAuth where You'll build an authentication system using Supabase and Next.js

3

Re-factor for Good

By the 2nd article in this series, we have set up a Next.js + Supabase codebase and introduced functions to let our users sign-up and sign in. The natural next step should be to create protected pages that will only be available to authenticated users. But, we'll take a slight detour to refactor our code a bit. Good solutions don't just work right, they scale well too!

All the authentication code is currently co-located with the /index page, even when authentication-related information and functions have app-wide utility. Any other page or component needing the user info or functions will have no way to access it without a few re-arrangements.

Lift the Authentication concerns up!

Functionalities like registrations, login, etc. make up for a part of an application that needs to be globally accessible. When we think of these kinds of use case in React, React Context comes as a recommended approach

  • to share and consume a global state
  • to share functions that can act on the global state

You can share the authentication details like a user's detail and login information, as well as functions like signUp, signIn through special context methods called (Context).Providers, and can grab whatever you need independent of where your component exists in the React's component tree through (Context).Consumer methods - render props or hooks

React's Context will be just the right fit for placing the auth concerns. It can simply be created as

import { createContext } from 'react'
const AuthContext = createContext(null)
export default AuthContext

Let's create a dedicated library folder auth for all the authentication related functions inside the lib directory. Once created, create a AuthContext.tsx inside /src/lib/auth with the following code

AuthContext.tsx
import { createContext, FunctionComponent } from 'react'

export type AuthContextProps = {/* to be defined */}

export const AuthContext = createContext<Partial<AuthContextProps>>({})

export const AuthProvider: FunctionComponent = ({
    children,
  }) => {
    return (<AuthContext.Provider value={{
                /* to be populated */
            }}>
            {children}
        </AuthContext.Provider>
    )
}

You now have the Provider shell ready. AuthContextProps defines the contract for what could be shared. Partial tells that it would be possible to initialize the context with an empty {}, even when AuthContextProps needs some mandatory values. Exporting AuthContext.Provider helps us take one more decision away from the code that'll consume the context, minimally exposing what's essential.

Pull-in the common concern With the above structure in place, let's pull in the signUp and signIn methods and all of their dependency functions, types within this context.

AuthContext.tsx
import { createContext, FunctionComponent, useState } from 'react'
import { supabase } from '~/lib/supabase' // dependency for signIn, signUp
import { useMessage } from '~/lib/message'  // dependency for signIn, signUp
import { SupabaseAuthPayload } from './auth.types'  // dependency for signIn, signUp - we'll define it shortly in a separate file

export type AuthContextProps = {/* to be defined shortly */}


export const AuthContext = createContext<Partial<AuthContextProps>>({})

export const AuthProvider: FunctionComponent = ({
    children,
  }) => {
    const [ loading, setLoading ] = useState(false)
    const { handleMessage } = useMessage()

    const signUp = async (payload: SupabaseAuthPayload) => {
        try {
          setLoading(true)
          const { error } = await supabase.auth.signUp(payload)
          if (error) {
            handleMessage({ message: error.message, type: 'error' })
          }
          else {
            handleMessage({ message: 'Signup successful. Please check your inbox for a confirmation email!', type: 'success' })
          }
        } catch (error) {
          handleMessage({ message: error.error_description || error, type: 'error' })
        } finally {
          setLoading(false)
        }
    }

    const signIn = async (payload: SupabaseAuthPayload) => {
        try {
            setLoading(true)
          const { error } = await supabase.auth.signIn(payload)
          if (error) {
            handleMessage({ message: error.message, type: 'error' })
          } else {
            handleMessage({ message: 'Log in successful. I\'ll redirect you once I\'m done', type: 'success' })
          }
        } catch (error) {
          handleMessage({ message: error.error_description || error, type: 'error' })
        } finally {
            setLoading(false)
        }
    }


    return (<AuthContext.Provider value={{
                 /* to be populated */
            }}>
            {children}
        </AuthContext.Provider>
    )
}

We now have both signUp and signIn methods within the Context function. Let's zero in on what we'd like to share with the rest of the application next

Share the Common Concerns

If you see it from the perspective of what methods were used so far, it's going to be everything we lifted and pulled in.

  • signUp so that we can sign-up from anywhere in the app
  • signIn so that we can sign in from anywhere in the app Today homepage / is using these methods, tomorrow dedicated /sign-up or /sign-in pages might use it. It could be even consumed in a pop-up to prevent unnecessary transitions and clicks. Additionally, we'll also pass a loading state so that consumers can show a progress indicator while the sin up or sign-in process is in-flight.

Let's populate the type AuthContextProps defining the shape of the value our AuthContext.Provider will be allowed to share

/* preceeding code (unchanged) */
export type AuthContextProps = {
    signUp: (payload: SupabaseAuthPayload) => void,
    signIn: (payload: SupabaseAuthPayload) => void,
    loading: boolean
}
/* succeeding code (unchanged) */

We'll have to update the Provider object too accordingly

/* preceeding code (unchanged) */
return (<AuthContext.Provider value={{
                 signUp,
                 signIn,
                 loading
        }}>
            {children}
        </AuthContext.Provider>
/* preceeding code (unchanged) */

Let's also define the auth payload's type in a separate src/lib/auth/auth.types.ts file

export type SupabaseAuthPayload = {
    email: string,
    password: string
}

With all of the above changes in place our AuthContext.tsx component should look like

AuthContext.tsx
import { createContext, FunctionComponent, useState } from 'react'
import { supabase } from '~/lib/supabase'
import { useMessage } from '~/lib/message'
import { SupabaseAuthPayload } from './auth.types'

export type AuthContextProps = {
    signUp: (payload: SupabaseAuthPayload) => void,
    signIn: (payload: SupabaseAuthPayload) => void,
    loading: boolean
}

export const AuthContext = createContext<Partial<AuthContextProps>>({})

export const AuthProvider: FunctionComponent = ({
    children,
  }) => {
    const [ loading, setLoading ] = useState(false)
    const { handleMessage } = useMessage()

    const signUp = async (payload: SupabaseAuthPayload) => {
        try {
          setLoading(true)
          const { error } = await supabase.auth.signUp(payload)
          if (error) {
            handleMessage({ message: error.message, type: 'error' })
          }
          else {
            handleMessage({ message: 'Signup successful. Please check your inbox for a confirmation email!', type: 'success' })
          }
        } catch (error) {
          handleMessage({ message: error.error_description || error, type: 'error' })
        } finally {
          setLoading(false)
        }
    }

    const signIn = async (payload: SupabaseAuthPayload) => {
        try {
            setLoading(true)
          const { error } = await supabase.auth.signIn(payload)
          if (error) {
            handleMessage({ message: error.message, type: 'error' })
          } else {
            handleMessage({ message: 'Log in successful. I\'ll redirect you once I\'m done', type: 'success' })
          }
        } catch (error) {
          handleMessage({ message: error.error_description || error, type: 'error' })
        } finally {
            setLoading(false)
        }
    }


    return (<AuthContext.Provider value={{
                signUp,
                signIn,
                loading
            }}>
            {children}
        </AuthContext.Provider>
    )
}

The AuthContext is now ready to be wrapped around our Next.js app so that what it contains can be available to the entire app.

Wire the App with AuthProvider Open /src/pages/_app.tsx and put the AuthContext.Provider around Next.js root Component

_app.tsx
 /* Pre-existing imports (unchanged) */
import { AuthProvider } from '~/lib/auth'

function MyApp({ Component, pageProps }: AppProps) {
    /* Pre-existing code (unchanged) */
      <AuthProvider>
        <Component {...pageProps} />
      </AuthProvider>
    /* Pre-existing code (unchanged) */
}

Once the changes are done, _app.tsx should look like

_app.tsx
import 'tailwindcss/tailwind.css'
import '~/styles/globals.css'

import React from 'react'
import type, { AppProps } from 'next/app'
import Head from 'next/head'
import { DefaultSeo } from 'next-seo';

import SEO from '../../next-seo.config';
import { MessageProvider } from '~/lib/message'
import { AuthProvider } from '~/lib/auth'

function MyApp({ Component, pageProps }: AppProps) {
  const pageMeta = (Component as any)?.defaultProps?.meta || {}
  const pageSEO = {...SEO, ...pageMeta }

  return <React.Fragment>
    <Head>
      <meta content="width=device-width, initial-scale=1" name="viewport" />
    </Head>
    <DefaultSeo {...pageSEO } />
    <MessageProvider>
      <AuthProvider>
        <Component {...pageProps} />
      </AuthProvider>
    </MessageProvider>
  </React.Fragment>
}

export default MyApp

The beauty of convenience functions

You can use the Auth context now anywhere in the app. All you need is a import from ~/lib/auth/AuthContext.tsx

import { AuthContext } from '~/lib/auth/AuthContext.tsx'

and then a use like

const { signIn, sigUp, loading } = useContext(AuthContext)

but why stop here if we can improve this further? Let's create a re-usable hook to make it a bit more simple to use the AuthContext.

useAuth Hook

Let's create a file useAuth.tsx under /src/lib/auth and place the following code

useAuth.tsx
import { useContext } from 'react'
import { AuthContext } from './AuthContext'

export const useAuth = () => {
    const context = useContext(AuthContext)

    if (context === undefined) {
      throw new Error(
        'useAuth must be used within an AuthContext.Provider'
      )
    }

    return context
}

The final exports

For making it a bit more convenient for our library users we'll export everything that's publicly usable through an index.ts file at src/lib/auth

export * from './auth.types'
export * from './AuthContext'
export * from './useAuth'

With this final now we have a well-organized auth library that can be referred from anywhere in the app with a simple import {...} from '~/lib/auth/' for the auth types, user objects, and auth functions

Clean-up the Landing Page

Replace all of the un-necessary imports like

import { useMessage } from '~/lib/message'
import { supabase } from '~/lib/supabase'

with one

import { useAuth } from '~/lib/auth'

Replace all of the un-necessary local states

const [loading, setLoading] = useState(false)
const { handleMessage } = useMessage()

with one

const { loading, signIn, signUp } = useAuth()

in the IndexComponent and see your light and clean page component fly :-).

You can find all of the changes here

along with few enhancements in the follow-up PRs

Takeaways and What's Next

Everything we re-arranged and re-factored should be non-breaking changes, and with the auth-related concerns lifted to the top of the component tree, your page-level code should feel lighter and should eventually have just the page-specific code.

These sorts of slight detours are necessary to course-correct our approaches to problem-solving as our applications evolve. It's always better now than late.

With all the heavy lifting done to separate the global and local concerns, we should be able to continue building the rest of SupaAuth's functionality like

  • allowing the signed users to view a protected page
  • redirection users to the log-in page if they're not logged in
  • logging out of the logged-in user

and more in the next few parts.

Thank you for being till the end 🙌 . If you enjoyed this article, or learned something new, support me by clicking the share button below to reach more people and/or give me a follow on Twitter to see some other tips, articles, and things I learn and share there.