DEV Community

Cover image for The magic of react-query and supabase
Ankit Jena
Ankit Jena

Posted on • Updated on

The magic of react-query and supabase

It's been a while since I wrote my last article on state management in React using Context. Here's the link for anyone who wants to give it a read. And using custom hooks is still the primary way for me for state management and I have been recommending that to people as well.

In the previous post I had mentioned about UI state(theme, ux state) vs Server state(fetched data). I want to follow up on the sequel article I had promised. Let's get into it.

What we are going to build

Let's not make yet another todo list. I think having some real world data will help understand things better. For this part we are going to make an app, where you can search movies from the TMDB api, add it to your profile as recommendation.

What we are going to use

  • NextJS - I, by default use NextJS for any react application I build nowadays over CRA.
  • react-query - Data fetching/caching tool, going to help us with our "global/server state problems"
  • supabase - Supabase is something I have fallen in love with. It is an open source alternative to firebase(auth, database, storage) but the best part is it's Postgres. This will serve entirely as our backend. You will see how.
  • tailwindcss - For styling our app.

Gotta say, all of these have the best developer experience you could ask for.

Let's get started.

Setting up the client

First we need to create the next app and setup tailwind in it.

Setting up the backend(supabase)

Login into supabase and create a project. By default supabase provides you with auth. In this tutorial I won't be going all out on auth(will just do the login). After you create databases, all of them are accessible through the supabase client using an anon key that you get when you create a project. This is also where the best part of their auth architecture comes into place. All of the data by default are accessible to anyone using the anon key. But you can use row level policies on each table to achieve role/auth based authorization.

Let's first create a few tables using the inbuilt SQL editor in the dashboard, based on what we are trying to build.

CREATE TABLE users (
  id uuid references auth.users PRIMARY KEY,
  name text,
  username text unique
);

CREATE TABLE movies (
  movie_id integer PRIMARY KEY,
  title text,
  poster_path text,
  overview text,
  release_date date
);

CREATE TABLE recommendations (
   id uuid NOT NULL DEFAULT extensions.uuid_generate_v4(),
   primary key(id),
   user_id uuid,
   constraint user_id foreign key(user_id) references users(id),
   movie_id integer,
   constraint movie_id foreign key(movie_id) references movies(movie_id)
);

CREATE UNIQUE INDEX "user_id_movie_id" on recommendations using BTREE ("movie_id", "user_id");
Enter fullscreen mode Exit fullscreen mode

You can create all the tables and relationships using the UI too if you want but you have both the options.
After running this the tables will be created for you. Let's see how our schema looks like using this schema visualizer.
alt text

Initializing the client

Let's install the client.

yarn add @supabase/supabase-js
Enter fullscreen mode Exit fullscreen mode

Create a file called app/supabase.ts and initialize the client.

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY);

export default supabase;
Enter fullscreen mode Exit fullscreen mode

Make sure you copy over the project URL and anon key from your dashboard and paste it in .env.local file.

Before we go further let's setup react-query as well.

React Query is often described as the missing data-fetching library for React, but in more technical terms, it makes fetching, caching, synchronizing and updating server state in your React applications a breeze.

Setting up React Query

Install the package using

yarn add react-query
Enter fullscreen mode Exit fullscreen mode

and add the following to your _app.js.

...
imports
...

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 0
    }
  }
})

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}
export default MyApp

Enter fullscreen mode Exit fullscreen mode

React query has default retry of 3 times for queries, you can set your custom ones. We have set it to 0. We are also using the devtools which is an awesome tool and helps us view queries and states easily.

Let's clarify a few things before going into this, react-query is data fetching and tool you can use anyway you like. A few people confuse this with Apollo Client, but Apollo Client is for GraphQL. React Query agnostic to what you are using to fetch data and just deals with promises. Which means you can deal with REST, GraphQL API, file system request as long as a promise is returned.

With React Query, queries are when you are fetching data from the server and mutations when you are changing data on the server.

Signup

In signup we would be using supabase auth to signup and also create a user in the database with additional details.

Create a page in pages/auth/signup.tsx, for the signup form

import { useRouter } from "next/router"
import { useState } from "react"
import Loader from "../../components/ui/loader"

export default function Signup() {
  const router = useRouter()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [name, setName] = useState('')
  const [username, setUsername] = useState('')

  return (
    <div className="min-h-screen grid place-items-center text-xl">
      <div className="w-2/3 lg:w-1/3 shadow-lg flex flex-col items-center">
      <h1 className="text-4xl font-semibold">Sign up</h1>
      <div className="mt-8 w-full lg:w-auto px-4">
          <p>Name</p>
          <input 
            type="text" 
            className="h-8 focus:outline-none shadow-sm border p-4 rounded mt-2 w-full lg:w-auto"
            onChange={e => setName(e.target.value)}
          />
        </div>
        <div className="mt-8 w-full lg:w-auto px-4">
          <p>Email</p>
          <input 
            type="text" 
            className="h-8 focus:outline-none shadow-sm border p-4 rounded mt-2 w-full lg:w-auto"
            onChange={e => setEmail(e.target.value)}
          />
        </div>
        <div className="mt-8 w-full lg:w-auto px-4">
          <p>Password</p>
          <input 
            className="h-8 focus:outline-none shadow-sm border p-4 rounded mt-2 w-full lg:w-auto"
            type="password"
            onChange={e => setPassword(e.target.value)}
          />
        </div>
        <div className="my-8 w-full lg:w-auto px-4">
          <p>Username</p>
          <input 
            type="text" 
            className="h-8 focus:outline-none shadow-sm border p-4 rounded mt-2 w-full lg:w-auto"
            onChange={e => setUsername(e.target.value)}
          />
        </div>
        <div className="mb-8 w-1/5">
          <button 
            className="bg-blue-500 text-white px-8 py-2 rounded w-full"
          >
            <span>Sign up</span>
          </button>
        </div>
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Let's create a custom hook in hooks/useCreateUser.ts

We can always have the fetching/mutating inside the component but having separate custom hooks gives rise to cleaner code.

import { useMutation, useQueryClient } from "react-query"
import supabase from "../app/supabase"

interface User {
  name: string;
  email: string;
  username: string;
  password: string;
}

const createUser = async (user: User) => {
  // Check if username exists
  const { data: userWithUsername } = await supabase
    .from('users')
    .select('*')
    .eq('username', user.username)
    .single()

  if(userWithUsername) {
    throw new Error('User with username exists')
  }

  const { data, error: signUpError } = await supabase.auth.signUp({
    email: user.email,
    password: user.password
  })

  if(signUpError) {
    throw signUpError
  }

  return data
}

export default function useCreateUser(user: User) {
  return useMutation(() => createUser(user), {
    onSuccess: async(data) => {
      const { data: insertData, error: insertError } = await supabase
        .from('users')
        .insert({
          name: user.name,
          username: user.username,
          id: data.user.id
        })

      if(insertError) {
        throw insertError
      }

      return insertData
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Let's go through the above code.

First we have the method the create the user. In there we first check whether an user with the username exists and if it does we throw an error. So a thing to notice here is that the supabase client by default doesn't throw an error, instead returns it in the return object. Then we use supabase.auth.signUp() method with email and password. We have disabled, the email verification in supabase auth dashboard for this tutorial. If it succeeds we return the data we get back.

Next we have the default export which uses the useMutation hook from react query. We pass in the function we created above. Also since we also want to insert a user in our users table, we have onSuccess side effect in options which gets the data returned by the createUser method. Here we use supabase.from to build a insert query and we use the user id returned from the signup success.

Perfect, now we add the logic in pages/auth/signup

...
import useCreateUser from "../../hooks/useCreateUser"

export default function Signup() {
...
  const createUserMutation = useCreateUser({
    email,
    password,
    name,
    username
  })

  if(createUserMutation.isSuccess) {
    router.push("/")
  }

...

{createUserMutation.isError && <p className="text-sm mb-8 text-red-500">{createUserMutation.error.message}</p>}

...

<button 
    className="bg-blue-500 text-white px-8 py-2 rounded w-full"
    onClick={() => createUserMutation.mutate()}
    >
            {createUserMutation.isLoading? 
              <span>
                <Loader 
                  height={30}
                  width={30}  
                />
              </span> :
            <span>Sign up</span>
            }
    </button>
Enter fullscreen mode Exit fullscreen mode

We import the custom hook and define it in our component. We add an onclick action on the button which triggers the mutation. We also use the isLoading, isError, error for displaying. We use the isSuccess to route the user to the home page.

Now on entering the details and clicking signup a user should be created, and you should be redirected to the signup page.

Login

Let's quickly add the login page as well.

Let's create a new page at auth/login route and add some simple ui.

export default function Login() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  return (
    <div className="min-h-screen grid place-items-center text-xl">
      <div className="w-2/3 lg:w-1/3 shadow-lg flex flex-col items-center">
        <div className="mt-8 w-full lg:w-auto px-4">
          <p>Email</p>
          <input 
            type="text" 
            onChange={e => setEmail(e.target.value)}
            className="h-8 focus:outline-none shadow-sm border p-4 rounded mt-2 w-full lg:w-auto"
          />
        </div>
        <div className="my-8 w-full lg:w-auto px-4">
          <p>Password</p>
          <input 
            className="h-8 focus:outline-none shadow-sm border p-4 rounded mt-2 w-full lg:w-auto"
            type="password"
            onChange={e => setPassword(e.target.value)}
          />
        </div>
        <div className="mb-8">
          <button className="bg-blue-500 text-white px-8 py-2 rounded">Login</button>
        </div>
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Create a similar hook called hooks/useLogin.ts

import { useMutation } from 'react-query'
import supabase from '../app/supabase'

const login = async ({email, password}) => {
  const { data, error } = await supabase.auth.signIn({
    email, 
    password
  })

  if(error) {
    throw new Error(error.message)
  }

  return data
}

export default function useLogin({ email, password }) {
  return useMutation('login', () => login({email, password}))
}
Enter fullscreen mode Exit fullscreen mode

And similarly in pages/auth/login.tsx

...
const loginMutation = useLogin({email, password})

  if(loginMutation.isSuccess) {
    router.push('/')
  }
...
...

{loginMutation.isError && <p className="text-sm mb-8 text-red-500">{loginMutation.error.message}</p>}
...
<button 
    className="bg-blue-500 text-white px-8 py-2 rounded w-full"
    onClick={() => loginMutation.mutate()}
  >
      {loginMutation.isLoading? 
        <span>
          <Loader 
            height={30}
            width={30}  
          />
        </span> :
        <span>Login</span>
      }
   </button>
Enter fullscreen mode Exit fullscreen mode

It's pretty similar to signup, we call the supabase.auth.signIn method and redirect the user if the mutation is successful.

Now if you enter your credentials, login should work.

Authenticated Pages

Now when the user logs in we want to fetch the user details, name and username in our case which will be available to the entire app. Let's create a hook for that.

Create a file in hooks/useUser.ts

import { useQuery } from 'react-query'
import supabase from '../app/supabase'

const getUser = async ({userId}) => {
  const { data, error } = await supabase
    .from('users')
    .select()
    .eq('id', userId)
    .single()

  if(error) {
    throw new Error(error.message)
  }

  if(!data) {
    throw new Error("User not found")
  }

  return data
}

export default function useUser() {
  const user = supabase.auth.user()
  return useQuery('user', () => getUser(user?.id))
}
Enter fullscreen mode Exit fullscreen mode

The useQuery hook needs a unique key as the first parameter. > At its core, React Query manages query caching for you based on query keys. Query keys can be as simple as a string, or as complex as an array of many strings and nested objects. As long as the query key is serializable, and unique to the query's data, you can use it! Read more here.

We define a getUser method which uses the supabase client query builder. This is equivalent to

SELECT * FROM users where id = <userId>
Enter fullscreen mode Exit fullscreen mode

In the default export, we use the supabase.auth.user() method which returns the user if session exists. Note the user?id in the getUser method call, this is because the auth.user method can initially return null and eventually resolves to a value.

Now we want to make our home page authenticated. So when a user doesn't have a session, he will be redirected to the login page.

To do that let's create a file in components/Protected.tsx

import Loader from "./ui/loader"
import { useRouter } from 'next/router'
import useUser from "../hooks/useUser"

export default function ProtectedWrapper({children}) {
  const router = useRouter()
  const { isLoading, isError } = useUser()
  if(isLoading) {
    return (
      <div className="h-screen grid place-items-center">
        <Loader height={200} width={200}/>
      </div>
    )
  }

  if(isError) {
    router.push('/auth/login')
    return (
      <div className="h-screen grid place-items-center">
        <Loader height={200} width={200}/>
      </div>
    )
  }

  return (
    <div>
      {children}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

This is a wrapper component which basically checks for the session and redirects if it's not there. Let's see how it happens. So we are using the useUser we defined earlier and destructuring isLoading and isError from the it. If it's loading, we display a loader and if the query errors we redirect the user.

The isLoading state happens when the query is being fetched for the first time, likely during component mount for the first time/window reload.

The isError state is when the useUser query errors. This is the beauty of react query. If the session doesn't exist, the supabase.auth.user() will never resolve to a value and the getUser call will throw an error.

Also when the value returned from supabase.auth.user changes from null to user, the query is automatically refetched.

Now let's use this ProtectedWrapper inside our index page.

...
import ProtectedWrapper from "../components/Protected"

export default function Home() {
  return (
    <ProtectedWrapper>
      ...
    </ProtectedWrapper>
  )
}
Enter fullscreen mode Exit fullscreen mode

Let's see it in action.
Alt Text
This one is when there is no session.
Alt Text
This one is where browser session exists.

Awesome, we can now use this wrapper in pages which we want to be authenticated.

Displaying the user

Let's create a Navbar component

import Link from 'next/link'
import Loader from "../ui/loader";
import { useRouter } from "next/router";

export default function Navbar() {
  return (
    <div className="flex items-center justify-around py-6 bg-blue-500 text-white shadow">
      <Link href="/">
        <div className="text-2xl">
          Home
        </div>
      </Link>
      <div className="text-xl flex items-center space-x-4">
        <div>
          <Link href="/search ">
            Search
          </Link>
        </div>
        <div>
          Username
        </div>
        <div
          className="cursor-pointer"
        >
          {/* Logout feather icon */}
          <svg 
            xmlns="http://www.w3.org/2000/svg" 
            width="24" 
            height="24" 
            viewBox="0 0 24 24" 
            fill="none" 
            stroke="currentColor" 
            strokeWidth="2" 
            strokeLinecap="round" 
            strokeLinejoin="round" 
            className="feather feather-log-out"
          >
            <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line>
          </svg>
        </div>
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now let's say we want to display the username in our Navbar, we don't have to do anything but reuse the useUser query again in the Navbar component. React query by default caches all queries for 5 mins(can be changed), after which the query is refetched. Here's how.

...
import useUser from "../../hooks/useUser"

export default function Navbar() {
  const { data, isLoading } = useUser({userId: user?.id})
  ...
      <div>
        {isLoading ? 
          <span>
            <Loader 
              height={30}
              width={30}
            />
          </span>
        : data?.username}
      </div>
    ...
Enter fullscreen mode Exit fullscreen mode

A few things that react-query takes care for us here

  • We didn't have to add any logic to share the state, we can just use the data from the hook
  • We get, the state object in navbar as well which we use to display a loading indication incase the user is being fetched

No declaring of many initial states, and dispatching of actions. :3

Log out

Let's also add the log out logic in the navbar. You know the script, create a hook and use the hook.

import { useMutation, useQueryClient } from "react-query"
import supabase from "../app/supabase"


const logout = async () => {
  const { error } = await supabase.auth.signOut()

  if(error) {
    throw error
  }
}

export default function useLogOut() {
  const queryClient = useQueryClient()
  return useMutation(() => logout(), {
    onSuccess: () => {
      queryClient.removeQueries()
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

We use the supabase.auth.signOut which destroys the session and logs the user out.
A thing to notice here is since our app uses queries to display data and not any kind of store, we need to remove the queries once a user logs out. To do that we use the queryClient from the useQueryClient hook and on the success side effect we remove all the queries using queryClient.removeQueries method.

...
import useLogOut from "../../hooks/useLogOut";
import { useRouter } from "next/router";

...

export default function Navbar() {
  const logoutMutation = useLogOut()
  const router = useRouter()

  if(logoutMutation.isSuccess) {
    router.push('/auth/login')
  }

  ...

  <div
          className="cursor-pointer"
          onClick={() => logoutMutation.mutate()}
        >
          <svg 
            ...
          </svg>
        </div>
Enter fullscreen mode Exit fullscreen mode

Done, clicking the logout button now destroys the session and redirects to the login page.

Searching for movies

We know the pattern now, let's create a hook for searching movies.
Create a file in hooks/useMovies.ts

import { useQuery } from 'react-query'

const searchMovies = async (query) => {
  const response = await fetch(`https://api.themoviedb.org/3/search/movie?api_key=${process.env.NEXT_PUBLIC_TMDB_API_KEY}&query=${query}&language=en-US&page=1`)

  if(!response.ok) {
    throw new Error('Error searching movies')
  }

  return response.json()
}

export default function useMovies({ query }) {
  return useQuery('movies', () => searchMovies(query), {
    enabled: false
  })
}
Enter fullscreen mode Exit fullscreen mode

The enabled: false here means the query doesn't run automatically and has to be manually triggered using refetch. More here

Create a page called search.tsx

import Navbar from "../components/layouts/navbar"
import Search from "../components/search"
import ProtectedWrapper from "../components/Protected"

export default function Home() {
  return (
    <ProtectedWrapper>
      <div className="min-h-screen">
        <Navbar />
        <div className="container mx-auto">
          <Search />
        </div>
      </div>
    </ProtectedWrapper>
  )
}
Enter fullscreen mode Exit fullscreen mode

And the Search component in components/search/index.tsx

import { useState } from 'react'
import useMovies from '../../hooks/useMovies'
import SearchResultItem from './SearchResultItem'
import Loader from '../ui/loader'

export default function Search() {
  const [query, setQuery] = useState('')
  const { refetch, isFetching, data, isSuccess, isIdle } = useMovies({query})
  return (
    <div className="mt-20 text-xl flex flex-col items-center">
      <div className="flex">
        <input 
          className="border shadow px-8 py-2 rounded focus:outline-none" 
          onChange={e => setQuery(e.target.value)}  
        />
        <button 
          className="bg-blue-500 py-2 px-4 shadow rounded text-white w-32"
          onClick={() => refetch()}
        >
          {
            isFetching ? 
            <span>
              <Loader 
                height={30}
                width={30}
              />
            </span>: 
            `Search`
          }
        </button>
      </div>
      <div className="mt-10">
        {isSuccess  && 
          <div className="grid place-items-center">
            {data
              ?.results
              .sort((a, b) => b.popularity - a.popularity)
              .map(
                (item, index) => 
                <SearchResultItem 
                  title={item.title} 
                  overview={item.overview} 
                  key={index}
                  poster_path={item.poster_path}
                  release_date={item.release_date}
                /> 
              )
            }
          </div>
        }
      </div>
        {isSuccess 
          && !data?.results.length
          &&   
          <div className="mt-10">
            <p>No results found</p>
          </div>
        }
      {isIdle && <div className="mt-10">Search for a movie</div>}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

And the search item component

import dayjs from 'dayjs'

export default function SearchResultItem({title, overview, poster_path, release_date}) {
  return (
    <div className="flex w-2/3 mt-4 shadow rounded py-2">
      <div className="h-30 w-1/4 grid place-items-center flex-none">
       <img src={`https://www.themoviedb.org/t/p/w94_and_h141_bestv2${poster_path}`} alt="poster" height="150" width="150" />
      </div>
      <div className="px-4 flex flex-col justify-around">  
        <p className="text-2xl">{title}</p>
        <p className="text-base">{overview.slice(0, 200)}...</p>
        <p className="text-base">{dayjs(release_date).format('YYYY')}</p>
        <button className="w-20 px-6 py-2 text-base bg-blue-500 text-white rounded">Add</button>
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now we can search for a movie and are displaying it in a list. One thing you will notice that even if you change pages and come back to the search page, the movie results if you had searched would have been cached and are shown. Woohoo.

Adding a movie to your recommendation

Let's create another hook for that.
In a file hooks/useAddMovie.ts

import { useMutation } from "react-query"
import supabase from "../app/supabase"

interface Movie {
  movie_id: number;
  title: string;
  overview: string;
  poster_path: string;
  release_date: string;
}

const addMovie = async (movie: Movie, user_id: string) => {
  const { error } = await supabase
  .from('movies')
  .upsert(movie)
    .single()

    if(error) {
      throw error
    }

    const { data, error: err } = await supabase
    .from('recommendations')
    .upsert({movie_id: movie.movie_id, user_id}, {
      onConflict: 'user_id, movie_id'
    })
    .single()

    if(err) {
      throw err
  }

  return data
}

export default function useAddMovie(movie: Movie) {
  const user = supabase.auth.user()
  return useMutation(() => addMovie(movie, user?.id))
}
Enter fullscreen mode Exit fullscreen mode

Note that we are using upsert in both the calls, one to save the movie details so a duplicate movie isn't added and second to prevent a duplicate entry in recommendation(we have the onConflict clause to satisfy the unique index constraint). Also we are using supabase.auth.user() to pass in the user id, for the second method.

Then in components/search/SearchResultItem.tsx

...
imports
...

export default function SearchResultItem({id, title, overview, poster_path, release_date}) {
  const addMovie = useAddMovie({
      movie_id: id, 
      title, 
      overview, 
      poster_path, 
      release_date
    })

  ...

        <button 
          className="w-32 px-6 py-2 text-base bg-blue-500 text-white rounded"
          onClick={() => addMovie.mutate()}
        >
          {addMovie.isLoading ? 
            <span>
              <Loader 
                height={25}
                width={25}
              />
            </span>: 
            `Add`}
        </button>
 ...
Enter fullscreen mode Exit fullscreen mode

Awesome now we can add a movie to our list. The last thing remaining is to display them in the home screen.

Displaying your recommendations

Create a file in hooks/useRecommendations.ts

import { useQuery } from 'react-query'
import supabase from '../app/supabase'

const fetchRecommendations = async (user_id) => {
  const { data, error } = await supabase
    .from('recommendation')
    .select(`
      movie (
        *
      )
    `)
    .eq('user_id', user_id)

  if(error) {
    throw new Error(error.message)
  }

  return data
}

export default function useRecommendations() {
  const user = supabase.auth.user()
  return useQuery('recommendations', () => fetchRecommendations(user?.id))
}
Enter fullscreen mode Exit fullscreen mode

Here we are fetching from the foreign table movie using the movie id foreign key and matching by the user id.

Let's update our components/recommendations/index.tsx

import Link from 'next/link'
import useRecommendations from '../../hooks/useRecommendations'
import MovieCard from './MovieCard'
import Loader from '../ui/loader'

export default function Recommendations() {
  const { data, isSuccess, isLoading } = useRecommendations()
  if(isLoading) {
    return (
      <div className="h-screen grid place-items-center">
        <Loader height={200} width={200} />
      </div>
    )
  }
  return (
    <div>
      <h2 className="text-3xl my-4">Your recommendations</h2>
      <hr />
      {isSuccess && !data.length && <div className="mt-20 text-xl grid place-items-center">
        <p>You have no recommendations yet.</p>
        <p>
          <span className="cursor-pointer text-blue-500"><Link href="/search">Search</Link></span>
          <span>{` `}for movies and add them to your recommendations.</span>
        </p>
      </div>}
      {
        isSuccess &&
        <div className="grid grid-cols-3 gap-x-4 gap-y-4">
          {data.map(({movie: {
              movie_id, 
              title, 
              overview,
              poster_path,
              release_date
            } }) => (
            <MovieCard 
              key={movie_id}
              title={title}
              poster_path={poster_path}
            />
          ))}
        </div> 
      }
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

And components/recommendations/MovieCard.tsx

export default function MovieCard({title, poster_path}) {
  return (
    <div className="grid place-items-center shadow rounded py-4">
      <img src={`https://www.themoviedb.org/t/p/w300_and_h450_bestv2${poster_path}`} />
      <p className="mt-4 text-2xl font-semibold">{title}</p>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Perfect, now when we load the home page we have a loader when the query is fetched. If you go into search and add a movie, you will see the home page will have fetched that automatically. That's because when move to a different page, the recommendations query becomes inactive and is automatically fetched again on component mount. If you open devtools you will also notice that the useUser query is also being fetched multiple times(when we go to a new page)

Stale queries are refetched automatically in the background when:
New instances of the query mount
The window is refocused
The network is reconnected.
The query is optionally configured with a refetch interval.

This behaviour is good but sometimes undesirable. Gladly we can configure it in query default options.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 0,
      refetchOnMount: false,
      refetchOnWindowFocus: false
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

We can also add this individually to a query. Now that we have disabled auto fetch on remount, we want to refetch the query when we add a movie from search page.

For this we can again use the queryClient from the useQueryClient hook. Here we want to use the refetchQueries method. If the query is currently being used in the same page, you can use invalidateQueries method which makes the stale and are refetched automatically. Since our use case is for a different page we will use refetchQueries instead.

In our hooks/useAddMovie.ts file

...
export default function useAddMovie(movie: Movie) {
  const queryClient = useQueryClient()
  const user = supabase.auth.user()
  return useMutation(() => addMovie(movie, user?.id), {
    onSuccess: () => {
      queryClient.refetchQueries('recommendations')
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Now when you add a movie, the query is refetched automatically.

The end result
Alt Text

React query has so many features, it's impossible to cover them all in a go. You can play around with react-query with an application, even better if you refactor an existing one to react-query.

The code until this point is on github

That's it for this part. In the next part we will build upon this app and add lists, which you can create and add your recommendations into and more features. We will delve more into supabase (row level policies etc), and more react query features.

Thanks for reading up to this point. If you have any questions or doubts feel free to ask them in the comments. If you liked the post like and share it on twitter.

Documentation links

Top comments (14)

Collapse
 
amodinho profile image
Amo Moloko

Excellent work. 🌟

Only thing I would do differently is to create a resuable function for storing your form state.

You use the following pattern:

//useState declearation
 const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  //in the JSX part of the component
          <input 
            type="text" 
            onChange={e => setEmail(e.target.value)}
        />
Enter fullscreen mode Exit fullscreen mode

Which could be upgraded to:

const [formState, setFormState] = useState({})

const updateFormState = (field,value) => {

   const newState = {
        [`${field}`]:value,
       ...formState
      }

     setFormState(newState)
}


    //JSX

      <input 
            type="text" 
            onChange={e => updateFormState("email",e.target.value)}
        />
Enter fullscreen mode Exit fullscreen mode
Collapse
 
hmenchaca profile image
hmenchaca

Amazing!

Collapse
 
ankitjey profile image
Ankit Jena

Glad you liked it!

Collapse
 
mathewthe2 profile image
Mathew Chan

Thank you for making this tutorial. This is almost exactly what I needed, but I used NextAuth.js to create the authentication table.

Collapse
 
ankitjey profile image
Ankit Jena

So did you connect nextauth with the supabase postgres instance

Collapse
 
mathewthe2 profile image
Mathew Chan • Edited

I did. Several things I had to do different.

Instead of .env.local, I had to add the supabase environment variables to next.config.js because the client supabase is declared client side instead of server side.

With nextauth I had to do an async call getSession() to request the userId first before querying because the userId isn't exposed to the default session object for security reasons

This was also my first time using Typescript so there were all kinds of issues trying to follow the code to get it working on my own project.

Thread Thread
 
ankitjey profile image
Ankit Jena

That's awesome.

Collapse
 
spiropoulos94 profile image
NikosSp

I am a simple man, I see React-Query, I press "LOVE"

Collapse
 
ankitjey profile image
Ankit Jena

Haha

Collapse
 
aquibbaig profile image
Aquib Baig

Really useful article, Ankit! Keep up the good work

Collapse
 
ankitjey profile image
Ankit Jena

Thanks Aquib

Collapse
 
exponent42 profile image
exponent42

This is excellent thank you

Collapse
 
exponent42 profile image
exponent42

This pattern continues to replace 99% of my state boilerplate. Thanks again Ankit, really

Collapse
 
reubence profile image
Reuben Rapose

Thank you for this <3