Implemeting more features Authentication Dashboard etc - vonschappler/Ultimate-React GitHub Wiki

Using React Query and Supabase for Server-Side data fetching operartions:

The most common data fetching operarios are juntcions, filtering, sorting and pagination. Below we'll present some simple snippets of code on how to implement those operarions directly on the server side, so the amount of data received by the client is optmized to only what is really requested. For the a fully and more complex code on how to proceed with this operations, check Supabase Documenatation or the final code of the project developed.

Queries selecting specific columns:

Those are queries usualy made in relational database on which we wish to fetch parts of the data instead of pulling all the data from a table.

// query code for selecting specific columns to be retrieved from table1
const { data, error } = await supabase
  .from('table1')
  .select('column1, column2, ..., column_n');

Queries with relation between tables:

Those are queries usualy made in relational database on which we wish to fetch data of multiple tables which are co-related each other.

// query code for fetching data from table1 while also pulling the references that table2 provide inside table1
const { data, error } = await supabase
  .from('table1')
  .select(
    'column1, column2, ..., table2_id, table2(column1_table2, column2_table2)'
  );

Queries with filtering:

Those are queries usualy made in relational database on which we wish to fetch data of multiple tables which are co-related each other.

// query code for fetching data while filtering the data to match the condition where columnName is equal to value
// there are many other filter operations, all listed on supabase documentation
const { data, error } = await supabase
  .from('table1')
  .select('*')
  .eq('columnName', 'value');

Queries with sorting:

Those are queries usualy made in relational database on which we wish to fetch data of multiple tables which are co-related each other.

// query code for fetching data while sorting the data on the ascending direction
// to change the direaction of sorting, the value of ascending should be set to false
const { data, error } = await supabase
  .from('table1')
  .select('*')
  .order('columnName', { ascending: true });

Queries with pagination:

Those are queries that fetch specific amount of data. for cases where the amount of data fetched is too big.

// query code for fetching limited ammount of data from 1 to 10
const { data, error } = await supabase.from('table1').select('*').range(0, 9);

Prefetching data with React Query:

Pre fetching consist in feching data that we know it may come necessary before it may be required to be rendered on the UI.

In the context of pagination this means that we already should fetch the next (or previous) page before it's effectivelly rendered.

The snippet of code below shows how to implement pre fetching using react query:

// file useBookings.js
import { useQuery, useQueryClient } from '@tanstack/react-query';
// other imports

export function useBookings() {
  // implements the client queryClient required for pre fetching
  const queryClient = useQueryClient();

  // other definitions

  // pre fetches the next page
  if (page < pageCount)
    queryClient.prefetchQuery({
      queryKey: ['bookings', filter, sort, page + 1],
      queryFn: () => getBookingsApi({ filter, sort, page: page + 1 }),
    });

  // pre fetches the previous page
  if (page > 1)
    queryClient.prefetchQuery({
      queryKey: ['bookings', filter, sort, page - 1],
      queryFn: () => getBookingsApi({ filter, sort, page: page - 1 }),
    });

  // rest of the code
}

As an alternative, it's also possible to use queryClient.prefetchInfiniteQuery (), but for information on how to use this, check the React Query documentation.


User authentication, authorization and more with Supabase:

Before explaining how to use the Supabase to authenticate an user, it's important to metion that when creating a supabase project, that comes with a defalt User database created for us.

This database can be found inside the Authorization section of your project and it's also by default empty.

Also it's important to mention that Supabase has some built in login methods, which can be enabled under Providers inside the Authorization Section.

With that said, in order to enable authentication while using supabase, first create an user with any email and password you desire.

Authenticating an user:

Then (following the code structure for the project), inside the folder services, a new file to manage the process of login in an user is defined, similar to the snippet below:

// file apiAuth.js

import supabase from 'path/to/supabase';

export async function login({ email, password }) {
  const { data, error } = await supabase.auth.signInWithPassword({
    email,
    password,
  });
  if (error) throw new Error(error.message);
  return data;
}

Also, since for the project under development, we decided to use React Query as part of our tech stack, we'll make use of this to manage this state, with a snippet code similar to what's provided below:

//  file useLogin.js

import { useMutation } from '@tanstack/react-query';
import { login as loginApi } from 'path/to/apiAuth';
// other imports

export function useLogin() {
  const { mutate: login, isLoading: isLoggingIn } = useMutation({
    mutationFn: ({ email, password }) => loginApi({ email, password }),
    onSuccess: () => {
      //this is important so we set manually some data to the query
      queryClient.setQueriesData(['user'], user.user);
      navigate('/dashboard', { replace: true });
    },
    onError: (err) => {
      console.log(err);
    },
  });

  return { login, isLoggingIn };
}

Authorizing an authenticated user to access routes:

Based on the structure of the project, the best way to protect access to the application for not logged in users is by encapsulating our ApplLayout layout component into a protected route.

This happens because in the way that our rotes were defined inside our application are all children of the app layout component. So, the code of AppLayout should now become something similar to:

// file AppLayout.jsx

import ProtectedRoute from 'path/to/ProtectedRoute';

// other definitions

function AppLayout() {
  return (
    <ProtectedRoute>
      <StyledAppLayout>{/* app layout components*/}</StyledAppLayout>
    </ProtectedRoute>
  );
}

export default AppLayout;

Because AppLayout is now encapsulated inside a ProtectedRoute component, this also needs to be created:

// file ProtectedRoute.jsx

// react imports

import { useUser } from 'path/to/useUser';
import { useNavigate } from 'react-router-dom';
import { useEffect } from 'react';

function ProtectedRoute({ children }) {
  const navigate = useNavigate();
  const { isLoadingUser, isAuthenticated } = useUser();

  useEffect(() => {
    if (!isAuthenticated && !isLoadingUser) navigate('/login');
  }, [navigate, isAuthenticated, isLoadingUser]);

  if (isLoadingUser) return <Spinner />;

  if (isAuthenticated) return children;
}

export default ProtectedRoute;

as well as it's also necessary to create the custom hook useUser.js and add a new function to the apiAuth.js:

// file apiAuth.js

// other functions definitions

export async function getCurrentUser() {
  const { data: session } = await supabase.auth.getSession();

  if (!session.session) return null;

  const { data, error } = await supabase.auth.getUser();

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

  return data?.user;
}
// file useUser.js

import { useQuery } from '@tanstack/react-query';
import { getCurrentUser } from 'path/to/apiAuth';

export function useUser() {
  const { data: user, isLoading: isLoadingUser } = useQuery({
    queryKey: ['user'],
    queryFn: getCurrentUser,
  });

  return {
    user,
    isLoadingUser,
    isAuthenticated: user?.role === 'authenticated',
  };
}

Login out an user:

In order to logout an user, all we need is a component to handle the logout process (this component should be rendered on some part of the common components, usually this is rendered on the header of a webiste) and a custom hook which will link up to the component to the logout function created on apiAuth.js, as shown below:

// file apiAuth.js

// other function definitions

export async function logout() {
  const { error } = await supabase.auth.signOut();

  if (error) {
    console.error(error);
    throw new Error(error.message);
  }
}
// file useLogout.js
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { logout as logoutApi } from 'path/to/apiAuth';
import { useNavigate } from 'react-router-dom';

export function useLogout() {
  const navigate = useNavigate();
  const queryClient = useQueryClient();
  const { mutate: logout, isLoading: isLoggingOut } = useMutation({
    mutationFn: logoutApi,
    onSuccess: () => {
      //  this is important so all queries saved in the cache are deleted
      queryClient.removeQueries();
      navigate('/login', { replace: true });
    },
    onError: (err) => {
      console.error(err);
    },
  });

  return { logout, isLoggingOut };
}
// file Logout.jsx

import { useLogout } from 'path/to/useLogout';

function Logout() {
  const { logout, isLoggingOut } = useLogout();
  return (
    <button disabled={isLoggingOut} onClick={logout}>
      Logout
    </button>
  );
}

export default Logout;

Creating a new user:

To create a new user, yet some pieces of code need to be added to our source code.

The first piece of code is to create a form where we'll be able to insert the new user information, as well collect this and submit the data to Supabase using React Query:

import { useForm } from 'react-hook-form';
import { useSignUp } from 'path/to/useSignUp';
// other imports

function SignupForm() {
  const { register, formState, getValues, handleSubmit, reset } = useForm();
  const { signUp, isCreatingUser } = useSignUp();

  const { errors } = formState;

  function onSubmit({ fullName, email, password }) {
    signUp({ fullName, email, password }, { onSettled: () => reset() });
  }

  return (
    <Form onSubmit={handleSubmit(onSubmit)}>
      <FormRow label='Full name' error={errors?.fullName?.message}>
        <Input
          disabled={isCreatingUser}
          type='email'
          id='email'
          {...register('email', {
            required: 'This field is required',
            pattern: {
              value: /\S+@\S+\.\S+/,
              message: 'Please provide a valid e-mail address',
            },
          })}
        />
      </FormRow>

      <FormRow
        label='Password (min 8 characters)'
        error={errors?.password?.message}
      >
        <Input
          disabled={isCreatingUser}
          type='password'
          id='password'
          {...register('password', {
            required: 'This field is required',
            minLength: {
              value: 8,
              message: `The password should be at least 8 characters`,
            },
          })}
        />
      </FormRow>

      {/* other form elements go here*/}
    </Form>
  );
}

export default SignupForm;

After the form is created, we need to add a sign up function to the apiAuth.js and make use of that in a custom hook (useSignUp.js) in order to connect the newly created form to Supabase using React Query:

// file apiAuth.js
import supabase from 'path/to/supabase';

export function signUp({ fullName, email, password }) {
  const { data, error } = supabase.auth.signUp({
    email,
    password,
    // this "options" object can be passed in order to provide additional data to be added to a new user upon creation
    //those values should be provided by the form where we create the user
    options: {
      data: {
        fullName,
        avatar: '',
      },
    },
  });

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

  return data;
}
// file useSignUp.js

import { useMutation } from '@tanstack/react-query';
import { signUp as signUpApi } from 'path/to/apiAuth';

export function useSignUp() {
  const { mutate: signUp, isLoading: isCreatingUser } = useMutation({
    mutationFn: ({ fullName, email, password }) =>
      signUpApi({ fullName, email, password }),
    onSuccess: (data) => {
      // definitions on how to proceed on a successful account creation
    },
    onError: (err) => {
      // definitions on how to process on in case of errors
    },
  });

  return { signUp, isCreatingUser };
}

With this, the application should be ready to go, with the whole authorization and authentication handled correctly.


Protecting Supabase with restricted access only to authenticated users:

To protect our database access, preventing data tampering, we need to set strict RLS. In order to do so, the database operations DELETE, INSERT, SELECT and UPDATE should be set on the Policies section of the Authentication menu to autenticated for all tables which needs protection.

In case your API needs to provide some public date for a external website, or for example a homepage from which some users need to get basic information (lets imagine an user trying to check prices of items in a online store), the SELECT operation has to be set to public, on the same menu.


Updating users data (user_metadata):

In order to update the users data, first we need a form which will be used to to colect the data which will be passed to Supabase. The componets should be created in a similar way to the previously form created in this section in the SignUpForm.jsx and no code for those will be provided here.

The methods for connecting the form to Supabase using React Query are also something similar to what's already done, so here we are just going to add the portions of code which are really relevant for the user update step, and not the whole process / code itself.

// file apiAuth.js

// imports and other definitions

export async function updateCurrentUser({ password, fullName, avatar }) {
  let updateData;
  if (password) updateData = { password };
  if (fullName) updateData = { data: { fullName } };

  const { data: updateName, error: updateNameError } =
    await supabase.auth.updateUser(updateData);

  if (updateNameError) {
    console.error(updateNameError);
    throw new Error(updateNameError.message);
  }

  if (!avatar) return updateName;
  const fileName = `avatar-${updateName.user.id}-${Math.random()}`;

  const { error: uploadError } = await supabase.storage
    .from('avatars')
    .upload(fileName, avatar);

  if (uploadError) {
    console.error(uploadError);
    throw new Error(uploadError.message);
  }

  const { data: updateAvatar, error: updateAvatarError } =
    await supabase.auth.updateUser({
      data: {
        avatar: `${supabaseUrl}/storage/v1/object/public/avatars/${fileName}`,
      },
    });

  if (updateAvatarError) {
    console.error(updateAvatarError);
    throw new Error(updateAvatarError.message);
  }

  return updateAvatar;
}
// file useUpdateUser.js

import { useMutation, useQueryClient } from '@tanstack/react-query';
//other imports

import { updateCurrentUser as updateCurrentUserApi } from 'path/to/apiAuth';

export function useUpdateUser() {
  const queryClient = useQueryClient();

  const { mutate: updateCurrentUser, isLoading: isUpdating } = useMutation({
    mutationFn: updateCurrentUserApi,
    onSuccess: () => {
      queryClient.invalidateQueries(['user']);
      // other success handling functions here
    },
    onError: (err) => {
      // error handling methods here
    },
  });

  return { updateCurrentUser, isUpdating };
}

Displaying some charts on our application:

As part of the requirements of the project, we have to display some specific data as charts on our dashboard. For that, in our tach-stack, it was defined that we'd use the Recharts 3rd-party library.

So, in order to make use of this, first we need to install it using:

# install a specific version
npm i recharts@<version_number>

# install latest version
npm i recharts@latest

Then in order to create some charts on the application we need to import and setup the chart just like shown in the snippet of code below, importing all the dependencies from recharts to the component file where the chart is being defined:

<ResponsiveContainer>
  <AreaChart data={dataToRender} width='100%' height='height-value'>
    <CartesianGrid strokeDasharray='4' />
    <Tooltip contentStyle={{ backgroundColor: 'tooltip-backgroun' }} />
    <XAxis
      dataKey='labelToRender'
      tick={{
        fill: 'fill-color',
      }}
      tickLine={{
        stroke: 'stroke-color',
      }}
    />
    <YAxis
      tick={{
        fill: 'fill-color',
      }}
      tickLine={{
        stroke: 'stroke-color',
      }}
    />
    <Area
      type='monotone'
      dataKey='dataToRender'
      stroke='stroke-color'
      fill='fill-color'
      strokeWidth={2}
      name='Data name'
    />
  </AreaChart>
</ResponsiveContainer>

For other chart types and definitions, check the documentation.


Error Boundaries

It's completly normal that some bugs may occasionaly appear in production. So, that means that every time that during development we see a white screen due to some rendering error, the users of the application should also see.

Of course this is not the desired behaviour, so we can fix these kind of bugs by using a React feature called Error Boundaries, which are like try-catch for for renders, which allows React to "react" to errors in render logic.

Error boundries are though, still very hard to use, because they are writting as class components and into a very weird and hard to use way.

Gladly, there is a 3rd-party library called react-error-boundary which makes the implementation of this feature something easy.

To make use of that we need to install it using the command below:

npm i react-error-boundary

The way it works is by warpping the entire application inside the returned component provided by the library, just like in the code below:

// main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ErrorBoundary } from 'react-error-boundary';
import App from './App.jsx';

import ErrorFallback from 'path/to/ErrorFallback.jsx';

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onReset={() => window.location.replace('/')}
    >
      <App />
    </ErrorBoundary>
  </React.StrictMode>
);

Where this ErrorFallback component should be something similar to:

// ErrorFallbackComponent.jsx

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div>
      <h1>Something went wrong</h1>
      <p>{error.message}</p>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

export default ErrorFallback;
⚠️ **GitHub.com Fallback** ⚠️