26
loading...
This website collects cookies to deliver better user experience
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");
yarn add @supabase/supabase-js
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY);
export default supabase;
.env.local
file.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.
yarn add react-query
_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
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.pages/auth/signup.tsx
, for the signup formimport { 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>
)
}
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
}
})
}
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.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.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>
isLoading
, isError
, error
for displaying. We use the isSuccess
to route the user to the home page.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>
)
}
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}))
}
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>
supabase.auth.signIn
method and redirect the user if the mutation is successful.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))
}
getUser
method which uses the supabase client query builder. This is equivalent toSELECT * FROM users where id = <userId>
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.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>
)
}
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.isLoading
state happens when the query is being fetched for the first time, likely during component mount for the first time/window reload.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.supabase.auth.user
changes from null
to user, the query is automatically refetched.ProtectedWrapper
inside our index page....
import ProtectedWrapper from "../components/Protected"
export default function Home() {
return (
<ProtectedWrapper>
...
</ProtectedWrapper>
)
}
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>
)
}
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>
...
react-query
takes care for us hereimport { 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()
}
})
}
supabase.auth.signOut
which destroys the session and logs the user out.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>
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
})
}
enabled: false
here means the query doesn't run automatically and has to be manually triggered using refetch
. More heresearch.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>
)
}
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>
)
}
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>
)
}
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))
}
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.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>
...
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))
}
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>
)
}
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>
)
}
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.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 0,
refetchOnMount: false,
refetchOnWindowFocus: false
}
}
})
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.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')
}
})
}
react-query
with an application, even better if you refactor an existing one to react-query
.