Understanding State Management Solutions

Tuesday 06 September 2022 · 7 min read

As your application grows, it helps to be more intentional about how your state is organized and how the data flows between your components. A redundant or duplicate state is a common source of bugs.

When it comes to thinking about State Management solutions, the first example that comes to mind is User authentication and authorization. Choosing a set of technologies to solve this is not an easy task and could even change from case to case subject to your business needs. You need to ask yourself how will these technologies make it easier to:

  • Navigate your Users through authentication
  • Manage UI changes as Users' state changes
  • Share state between components (regardless if you'll prop drilling or not)
  • Create performant applications at scale

To understand the problems that a good state management solution solves, this article provides an example based on the following technologies:

  • Supabase Authentication && Database: authentication with Row Level Security synced with a full Postgres database and OOTB (out of the box) APIs to interact with your Supabase instance.
  • TanStack Query v4 (hereafter referred to as React Query): declarative asynchronous state management solution for modern applications.

Before anything, React Query is NOT a data fetching library

Let's touch base on a common misconception: React Query is NOT a data fetching library. Even if it does simplify data fetching in React applications, it does not fetch any data at all on its own. Whenever you introduce React Query in your application, you'll quickly realize that you need an actual data fetching library to get the data before anything happens, such as Fetch API, Axios, etc. More on this can be found on Dominik's React Query as a State Manager.

Storing User Data

Creating a Supabase instance (or project) is as easy as signing up and following the steps until you reach your instance's dashboard. Once there, here are a couple of things you'll want to configure in Authentication > Configuration > Settings:

  • User Signups: enabled (for Guest authentication)
  • Redirect URLs: the base domain for both your development and production URLs
  • Auth Providers: Email (for Guest authentication) && GitHub

Social Providers Authentication

Supabase Auth supports several Social Providers to choose from. This example is using GitHub for authentication. In addition to just authenticating and using the redirect URLs declared in your Supabase instance, It is very likely that you will allow Users to authenticate from different locations within your application. For this use case, it's great to note that Supabase's signIn() method accepts a redirect object where you can provide the location where you want to direct the User once they're successfully authenticated.

const signInWithGitHub = async () => {
    const { user, session, error } = await supabase.auth.signIn(
        {
            provider: "github",
        },
        {
            redirectTo: `${
                process.env.NODE_ENV === "development"
                    ? `http://localhost:3000`
                    : `https://hectorsosa.me`
            }`,
        }
    );
};

Developer tip: you can use your application's environment variables to choose between a development or production location.

After authenticating Users (regardless of the login method you choose), what happens next? You will likely want to store and query their information as they use your application. So let's figure out how to get this done...

Syncing Supabase's Authentication and User data

When users sign up, Supabase assigns them a unique ID. You can reference this ID anywhere in your database.

Supabase doesn't create tables/records to store User information. Even if it does provide Users with a unique ID and keeps their authentication records stored in an auth table, to query and store additional user-related information, you need to take additional steps to achieve this. Here's what you'll need (following our example):

  • public table profile
  • Database Function create_profile_for_user
  • Database Trigger create_profile_for_user_trigger

Using Database Functions + Triggers

Postgres has built-in support for SQL functions that can you can leverage to accomplish data changes directly within your database. Here's how we'll make use of them:

Create a Database Function under the same Schema as the table (public) and with a Trigger return type (based on where it'll be invoked from). Since our Supabase instance provides a full Postgres database, we have access to write our function using plpgsql. Here's a snippet of how this function would look like:

begin
    insert into public.profile(id, email)
    values(new.id, new.email)
    return new;
end;

Before confirming this function, do not forget to set its security type to Security Definer which will execute the function with admin privileges. Now we need a trigger to execute this function...

Create a Database Trigger that listens to any single (row orientation) insert events in the auth table triggered after the operation has completed.

Disclaimer: make sure that this integration is well-tested before deploying to production as it may prevent Users from signing up.

Alright, so now the User is fully authenticated and their information is synced in our db, what happens next? Now we need to figure out how to manage state within our application for those authentication changes:

Managing User State

A query is a declarative dependency on an asynchronous source of data that is tied to a unique key.

Here's where React Query shines. A query can be used with any Promise-based method to fetch data from a server. However, before we get there, very much like any global state management solution, we need to enable a context provider at the parent level of our application. If you are using a Next.js application, here's the boilerplate you'll need for our example:

import {
    Hydrate,
    QueryClient,
    QueryClientProvider,
} from "@tanstack/react-query";
import { useState } from "react";

export default function MyApp({ Component, pageProps }) {
  const [queryClient] = React.useState(() => new QueryClient())

  return (
    <QueryClientProvider client={queryClient}>
      <Hydrate state={pageProps.dehydratedState}>
        <Component {...pageProps} />
      </Hydrate>
    </QueryClientProvider>
  )
}

So after that's taken care of, what parameters do we need to subscribe to a query using React query's useQuery hook:

  • A unique key for the query (functions as a string type identifier),
  • A function that returns a promise that either resolves the data or throws an error
async function getUser() {
    const user = supabase.auth.user();
    if (user) {
        const { data, error } = await supabase
            .from("profile")
            .select(`id, email, preferred_name`)
            .eq("id", user.id)
            .limit(1)
            .single();
        return data ? data : error;
    } else {
        return null;
    }
}

const { isLoading, data: user, refetch } = useQuery(["user"], getUser);

/* The query will manually revalidate at Auth changes */
supabase.auth.onAuthStateChange(() => {
    refetch();
    // Can also user queryClient.invalidateQueries([unique_key])
    // It would require you to get QueryClient from the context
});

/* Once you have access to User data you can access it from anywhere */
user && console.log(user)

The unique key you provide is used internally for refetching, caching, and sharing your queries throughout your application. Fetch once and access anywhere in your application.

Final thoughts

Oversimplifying things, this solution does wonders because any number of requests throughout your application using the same key will be resolved using a single network request. So React Query is probably one of the best state management alternatives out there that can be coupled with any data fetching library to manage global state. For more on this concept, I'd recommend Dominik's React Query as a State Manager

Thanks for reading!