React | NextJS

13.1 API Handling, Server Actions, and Context API

Module Still Under Development

# NextJS API Handling

When building React Apps you are basically creating a SPA. With NextJS we get server-side routing for free. We are building pages, not just components inside one page.

For each folder you have a page.js file. Combined with the parent layout.js this creates the index.html file for the current route. These pages are used on the server side to create the HTML before it gets sent to the browser.

You still have reusable components. Inside the app folder you should create a components folder where you can save all the reusable components. These components can be server side ones or you can add the use client directive on the first line to make them into a component that will be streamed to the browser and rendered with client side JavaScript.

Since your pages are server side script, it means that you can make the fetch calls from the server side too.

If you consider the APIs that you are building in MAD9124, then you will remember that server side fetch calls do NOT have the CORS restrictions that the browsers have. They also can include API keys because this script never gets sent to the browser. There is no security risk having your API key in the server side JS. With a client side script, including your API key does expose the key to anyone who visits your website.

With server side fetching, we need no state variables or hooks like useEffect and useState.

Here is a simple example of how a page that uses fetch would be set up.

// some page.js file as an example

export default async function Page(props) {
  let url = `https://some.api.com/example`;
  let resp = await fetch(url);
  if(!resp.ok) {
    //handle the error
    return <div>{Error message for user}</div>
  }else{
    //render the content
    let data = await resp.json();
    return (
      <div>
        {/* This is the content for your page */}
      </div>
    )
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# params and searchParams

When you have a dynamic route created by a folder name like [category], the props object passed into the page.js file will include a property object called params. It will contain the value from the URL as a property named after the folder.

// file - /[category]/.page.js
// rendered from a URL of '/hello'

export default async function Page(props) {
  //destructure props to pull out params
  const { params } = props;
  console.log(params); // server side console output will be {category:'hello'}
}
1
2
3
4
5
6
7
8

This means we can read the portion of the pathname of the url from props.params.

Sometimes we also want to pass data directly to the page inside a dynamic route. Eg: http://localhost:3000/hello?id=123 to load the /[category]/page.js file and give it an id param from the query string.

// page for `http://localhost:3000/hello?id=123` is /[category]/page.js

export default async function Page(props) {
  //destructure props to pull out params
  const { params, searchParams } = props;
  console.log(params); // server side console output will be {category:'hello'}
  console.log(searchParams.id); // server side console output will be 123
}
1
2
3
4
5
6
7
8

The query string values can be used in fetch calls or other ways to create specific data.

# route.js Files

Another fantastic capability that we get from NextJS is the ability to create API folders that are part of our website.

To create endpoints for your own API, just create a folder and place a route.js files inside the folder. The convention is to add a folder called api inside of the app folder. All your route folders will go inside the api folder. These route.js files are known as Route Handlers.

Let's say that we want to have an API with the following end points:

  • https://localhost:3000/api/cats
  • https://localhost:3000/api/dogs

It will have the following directory structure:

- app
  - api
    - cats
      - route.js
    - dogs
      - route.js
1
2
3
4
5
6

Inside each of the route.js files, you can create an exported function named for each HTTP Verb.

// /app/api/cats/route.js
import { cookies, headers } from 'next/headers';
//import cookies to be able to access, set and get cookie values
//import headers to directly access the headers
// inside any function you can do something like this:
//const cookieStore = cookies()
//const token = cookieStore.get('token')
//const headersList = headers()
//const referer = headersList.get('referer')
//both these can also be accessed from the request object


export async function GET(request) {
  //each method function will get passed the request object
  const url = new URL(request.url);
  const params = new URLSearchParams(url);
  //each method should return a response object
  //we can do a fetch call to another api from here
  let resp = await fetch('some other api url', {
    method: 'GET',
    headers: {
      accept: 'application/json',
      'API-Key': process.env.DATA_API_KEY,
      //read a value from environment variables for the api key
    },
    next: { revalidate: 60 }
    //we can set the results as valid for 60 seconds
  });

  return new Response('contents of response', {
    headers: { 'content-type': 'text/plain' },
    status: 200,
  });
}
export async function POST(request) {
  //each method function will get passed the request object
  //function can be async if you plan to do something asynchronous
  const url = new URL(request.url);
  const params = new URLSearchParams(url);
  //we can do fetch calls from here too
  let resp = await fetch('some other api url', {
    method: 'POST',
    headers: {
      accept: 'application/json',
      'API-Key': process.env.DATA_API_KEY,
    }
    body: JSON.stringify({id: 123, name: 'Steve'})
  });
  if (!resp.ok) {
    return new Response('{"code":444, "message":"fail"}', {
      headers: {
        'content-type': 'application/json',
      },
      status: 444,
    });
  }
  //extract the json data from the fetch
  const data = await resp.json();
  //convert or select whatever part you want then return it
  return Response.json({ data });
}
export function PATCH(request) {}
export function PUT(request) {}
export function DELETE(request) {}
export function HEAD(request) {}
export function OPTIONS(request) {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66

You can create a function for any HTTP methods you need. They can return any type of response you need. They can return JSON, HTML, XML, text, or even images.

Since this code is all running on the server side, it means that we can also access Environment variables from the server. Notice inside the fetch call in the POST function there is a header that uses process.env.DATA_API_KEY as its value.

In NodeJS you can use process.env to access any and all environment variables on the server.

If you are using Vercel or Netlify, then part of the configuration for any project will include creating environment variables to hold things like API keys.

Additionally, all the fetch call results will automatically be cached. Inside the fetch() options object we can add a property called next whose value will be an object with a revalidate property that holds a number of seconds. This is the length of time to hold the fetch results in the cache, to save processing time and bandwidth.

# Server-Side Redirects

If you ever want to do a redirect on the server-side to a different URL then you can import and use the redirect method like the following.

import { redirect } from 'next/navigation';

export function Page(props) {
  redirect('https://nextjs.org/');
}
1
2
3
4
5

# Paths for imports

When creating your components or pages, the paths to your imports are relative. They will be different depending on which file is doing the importing.

NextJS has a great solution to simplify your imports.

With the following directory structure, let's say we are building the page.js file inside the /app/[category]/product/ folder.

- app
  - components
    - Header.js
  - [category]
    - page.js
    - product
      - page.js
1
2
3
4
5
6
7

The standard way for product/page.js to import the Header.js component would be:

import Header from '../../components/Header';
1

If [category]/page.js were importing the same file, then the import would be:

import Header from '../components/Header';
1

As your application becomes more complex, the calculating of imports can be a bit more difficult too. To address this, we have a different import syntax for NextJS that we can use.

import Header from '@/app/components/Header';
1

The @/app is a direct pointer to the app folder from anywhere in the directory structure.

You can build your own custom import shortcuts by editing the jsconfig.json file.

# Server Actions

Server Actions are an alternative to route handlers. They let you embed a function for handling form submissions inside the same server-side component or page as the form.

First, in your <form> you will use an action attribute instead of an onSubmit attribute. The value of the action will be the name of the function inside the page function that contains the form.

When the handleSubmit server-side function is called, it means that NextJS has bundled everything in your Form as a FormData object and sent it to the server. The FormData object will be extracted from the Request object and passed to your server-side Server Action function.

You MUST add the use server directive as the first line inside your function.

// /something/page.js
import MySubmitButton from '@/app/components/MySubmitButton'; //a client side component

export default function Page() {
  async function handleSubmit(formData) {
    'use server';
    //await fetch or whatever async thing you want
    //then tell the current route to update itself
    revalidatePath('/something');
  }

  return (
    <form action={handleSubmit}>
      <input type="text" placeholder="Your name" />
      <MySubmitButton />
    </form>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

And the submit button client-side component can use the useFormStatus hook to determine the current status of the <form>. This is waiting for the form data to be sent to the server and a response back from the server.

'use client';
import { useFormStatus } from 'react-dom';

export default function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button disabled={pending} className="disabled:bg-zinc-500">
      Click Me
    </button>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12

With the disabled attribute in the button using the pending value, it will be set as disabled after the first time the user clicks the button. It will not get re-enabled until the result is streamed back from the server.

Any time you have a simple async task to complete on the server, Server Actions are a quick and easy way to handle this.

Another way that you can manage your server actions is to create a single file at the root called actions.js. Place all your server action functions inside this file and have the use server directive at the top of the file. With the file marked as a server side script you can import any of the functions into your client components elsewhere in your app.

Here is an example of an actions.js file with a couple server action functions. Remember that action functions must be async.

// /app/actions.js
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function zeroOne(something) {
  let num = Math.round(Math.random()); //return 0 or 1
  //save the value somewhere that can be retrieved when page.js runs again
  //then call revalidatePath or redirect
  revalidatePath(`/${something}`);
}
export async function waitZeroOne(something) {
  //return 0 or 1 after a 2 second delay
  let num = await new Promise((resolve) => setTimeout(() => resolve(Math.round(Math.random())), 2000));
  //save the value somewhere that can be retrieved when page.js runs again
  // revalidatePath(`/${something}`);
  redirect(`/${something}?${num}`);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

The server action functions can call revalidatePath() from 'next/cache' or redirect() from 'next/navigation' to effectively reload the page.js that triggered the action.

Then we import those functions in our client side component with event handling.

// /app/components/MyButton.js
'use client';
import { waitZeroOne } from '@/app/actions';

export default function MyButton({ page }) {
  return (
    <div className="p-8">
      <button onClick={() => waitZeroOne(page)}>click me</button>
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11

The client side button component needs to be used in a page, of course.

// /app/[something]/page.js

import MyButton from '@/app/components/MyButton';

export default function Page({ params, searchParams }) {
  const { something } = params;
  const num = searchParams.num;

  return (
    <div className="p-8">
      <h1 className="p-8">Page {something}</h1>
      {num && <p>The number is {num}</p>}
      <MyButton page={something} />
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

The path part of the url is passed to the button through props. The button calls the server action. Then the server action function has to call revalidate so the generated value can be passed back to the page. In this example, the pathname is passed to the server action function.

Here is a repo with a demo of the Server Actions tied to a form (opens new window). It takes two values from a form and uses one for the folder name and the second as a querystring value.

# Suspense

The <Suspense fallback=""> component that we discussed in React, works well with NextJS too.

In this following example, there is a dynamic component that will be making a fetch call using an async component function. We want the rest of the page to appear in the browser before the fetch completes. When completed, we want the contents of the dynamic component to be streamed to the browser and replace the <Suspense>.

// app/page.js

import {Suspense} from 'react';
import TheDynamicDataComponent from '@/app/components/TheDynamicDataComponent';

export default function Page(){

  return (
    <main>
      <header>
        <h1>Static Masthead content</h1>
      <header>
      <section>
        <Suspense fallback='<p>Loading...</p>'>
          <TheDynamicDataComponent />
        </Suspense>
      </section>
      <footer>
        <p>Static footer content</p>
      </footer>
    </main>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

And here is an example of a the TheDynamicDataComponent.

// /app/components/TheDynamicDataComponent.js

export default async function TheDynamicDataComponent() {
  //Note the async
  //fake a fetch call
  await new Promise((resolve, reject) => {
    //wait 3 seconds before continuing
    setTimeout(resolve, 3000);
  });

  return <p>This is the dynamic data that took 3 seconds to build.</p>;
}
1
2
3
4
5
6
7
8
9
10
11
12

# Middleware

When you build APIs with NodeJS and Express, you are frequently using middleware. These are functions that run as part of a sequence that starts when the HTTP request arrives at the server and finishes with the server sending the response back to the browser.

NextJS also has middleware. These are functions that can run before your page.js.

They are most commonly used when you need to do something like validate a user before sending a response. You can use the middleware to do anything you want though. To create the middleware, simply add a file called middleware.js inside your app folder and give it a function named middleware and a variable called config.

The middleware function will automatically be called (convention over configuration). It will be passed the request object.

The config variable needs to contain an object with a property called matcher. The matcher property will hold an array with a list of routes to match. For each of these routes, the middleware function will be called before your page.js.

The request parameter passed to the middleware function will be a NextRequest object. You need to import the NextRequest and NextResponse objects from next/server. The middleware function needs to return a call NextResponse.next(), which will trigger the next step in the process of layout.js -> page.js -> components, etc. that ends with sending the response to the browser.

Inside your middleware function you will be able to do server-side redirects to other pages if you want. Just use the NextResponse.redirect() method.

import { NextResponse, NextRequest } from 'next/server';

export function middleware(request) {
  const isAuthenticated = false;

  if (!isAuthenticated) {
    return NextResponse.redirect('/login');
  }
  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard', '/account'],
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# Context API

React Context API reference (opens new window)

The Context API is one way that you can manage global state data within your React Application.

The Context API is designed to work on the client side when managing state values in a single page application.

We have already covered how you can create state variables in every component and how you can pass those values down from a parent to a child through props. We can create a state variable in the App component and then pass it down through props to every other component in the web app. We can also pass down the set function for the App's state variable through props.

There is nothing stopping any developer from passing everything through props. However, it does make for a lot more code and therefore more difficult maintenance, plus wasted memory by passing all the values and objects from the top.

Instead, with Context we can create a global object that has a useContext hook which can be called directly from any component that needs access to the information.

Context

Context is not a replacement for your state variables inside components. It is meant to aid the few things that you will actually need in multiple components in different areas of your app.

If you have settings like a user's name and whether or not they are logged in. This would be perfect for storage inside a Context object.

If you have a large dataset that will have multiple components that display part of the data and other components that need to edit parts of that data, then Context would be useful too.

# Context and Provider Objects

The way the Context API gets used is by having a global Context Object, which will have a Provider property. The Provider is the part that gets wrapped around your whole app, in the same way that the BrowserRouter component gets wrapped around the whole app.

Whereever you place the Provider is the highest level that the data can be accessed. Typically this is done in the <App> component. The Provider is a function that gets called to load, change, or access the data stored in the Content object.

Let's create a basic Context object that will hold a username so that it can be accessed from anywhere in the app. Start by creating a file to hold the context object.

//UserContext.js
import { createContext, useState, useContext } from 'react';

const UserContext = createContext(); //create the context object

function UserProvider(props) {
  //create the provider object
  //it has a props object just like all component functions
  const [username, setUsername] = useState('');
  //state variable to hold the username

  return <UserContext.Provider value={[username, setUsername]} {...props} />;
  //create and return a Context-Provider component.
  //it must have a value property which holds the state variable and a function
  //the function is usually the set function returned by useState...but can be another
  //{...props} destructuring adds any other props passed into the provider
  //These props will include ALL the components nested inside the Provider (children)!
}

function useUser() {
  //create a custom hook that can be called from components
  const context = useContext(UserContext);
  //we use the built-in useContext hook to access our own Context object.
  if (!context) throw new Error('Not inside the Provider');
  return context; // [username, setUsername]
  //we are returning our own state variable and function from UserContext Provider
}

//export the hook and the provider
export { useUser, UserProvider };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

We are exporting the hook useUser that will be used in various Components to access the data held inside the Context Object. We are exporting the Context Provider UserProvider to be the wrapper around the part of the app that needs to access the Context data.

The UserProvider export gives us a <UserProvider></UserProvider> component that will wrap other components. The return statement for the Provider function includes this {...props} which will contain ALL the nested children components inside <UserProvider>.

props.children

Inside every props object passed to a component will be the actual contents that were written in the JSX. Learn more about props.children (opens new window). More about this later.

This UserContext.js file is normally put inside a folder called /src/context/. It will be imported at the root level where we want our data (for the UserProvider) and then again in any component file (for the hook) where we want to access the username.

Assuming that we want the Context Provider to wrap all the components that are inside our <App> component, will will import UserProvider there.

//App.js
import { UserProvider, useUser } from '../context/UserContext.js';
import { useEffect } from 'react';
import Header from './Header.js';
import Main from './Main.js';
import Footer from './Footer.js';

export default function App() {
  const [username, setUsername] = useUser();
  //because we are setting the value of username in the context object
  //from here, we need to import the useUser hook too.
  useEffect(() => {
    //this runs on initial load of <App>
    setUsername('Steve');
    //we will just hardcode a value to use for username
    //for simplicity's sake
  }, []);

  return (
    <UserProvider>
      {username && <h1>Hello {username}</h1>}
      <div>
        <Header />
        <Main />
        <Footer />
      </div>
    </UserProvider>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

The <UserProvider> element that we wrote above here will be converted into <UserContext.Provider value={[username, setUsername]} {...props} />. Then the {...props} will be replaced with any props plus all the child components that were written inside of <UserProvider>.

We ARE allowed to import and use more than one Context Provider in our web apps.

# The Custom Hook

Once we have a Context Provider and custom hook defined in UserContext.js, and the <UserProvider> wrapping other components in App.js, then we can start giving any nested component access to the data.

We do NOT need to start putting props that hold data in our components to pass down to the children components.

If you want to set a value for the state variable which is inside of the Context object, then you need to import your useUser custom hook.

The custom hook acts a lot like useState. It returns a reference to the value from Context AND a function to use in updating that value.

import { useUser } from '../context/UserContext.js';

const [user, setUser] = useUser();
setUser('some new value');
//you will have set the initial default value inside the Context Provider function
1
2
3
4
5

In any Component JavaScript file you can import the custom hook to gain access to the value and the function to change the value.

Sometimes the provider(s) will be placed inside the index.jsx / main.jsx at the root, instead of the App.jsx. As long as the provider is above all the components that will need access to the data that the provider controls.

# LocalStorage Custom Hook

LocalStorage is a good example of something that is read once and written to many times. LocalStorage can be used with Context. LocalStorage can be wrapped inside a custom hook that we build ourselves.

The data that is saved in LocalStorage and used in a web app should have a single source of truth. You need to have a single variable that is used by the running application. This is the single source of truth.

When the data needs to be updated, you update that one variable.

When you need to display the data, you get it from the one variable.

After updating the variable, you update the backup data in LocalStorage.

When the app loads initially, you read from LocalStorage to set the initial value in the variable.

We can create a custom hook for dealing with LocalStorage, which can be accessed from anywhere in the app. It will do the initial read to get the data out of LocalStorage. It will also be responsible for updating the backup version of the data from the variable.

If you wanted to get the initial data from a remote source you could use fetch or axios to retrieve the data and set it as the initial value in LocalStorage AND in the variable.

Here is a sample custom hook that we can use to manage values in LocalStorage.

import { useState, useEffect } from 'react';

export function useLocalStorage(key, initialState) {
  //when calling our custom hook we pass in a key and an initial value.

  const [state, setState] = useState(() => {
    const storedValue = localStorage.getItem(key);
    return storedValue ? JSON.parse(storedValue) : initialState;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(state));
  }, [state]);

  //each time useLocalStorageState is called it will return a reference
  // to the state variable and the function to update the value
  return [state, setState];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# useReducer Hook

The useReducer hook is an alternative to the useState hook. It is useful when you are using some type of state management system to update your state values. This is only necessary when you have complex state objects and want to control many different scenarios that could happen to your state values.

Here is a short video about the concept of a reducer.

And here is the signature of the useReducer hook. It accepts a reducer function that does something like this: (state, action) => newState. The useReducer hook will return an array with the current state object plus a method to call when you want to update your state object.

const [state, dispatch] = useReducer(reducer, initialArg);
1

The initialArg is the initial value for your state object.

Here is an example that shows building a Counter Component and the reducer function knows that it is supposed to add or deduct one from the state.count property based on the value of the type property passed to the dispatch method.

const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
    </>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# useCallback Hook

The useCallback hook lets you create a memoized version of your callback function with dependencies.

const memoizedCallback = useCallback(
  () => {
    doSomething(a, b); //the function call that gets memoized
  },
  [a, b] //the dependencies mapped to the function
);
1
2
3
4
5
6

A new memoized version of the callback function is created if those dependencies change.

# useMemo Hook

Let's you memoize a function's return value based on the parameters that are passed to it. Memoizing relies on pure functions - a function that always returns the same output when given the same input. If you have to call a function repeatedly with the same small set of values then you should consider saving the results in an object where the different input values are the keys and the output values are the key values.

Here is a short video about the concept of Memoization.

And here is a sample demonstration of the useMemo hook in action.

const memoizedValue = useMemo(() => {
  //call this function and remember the result
  computeExpensiveValue(a, b);
}, [a, b]);
//this is an array of dependencies to watch
//the dependencies are the values being passed into the function.
//use as many params as you need for your function.
//the returned value will be remembered as a result of that combination of params.
1
2
3
4
5
6
7
8

The useMemo hook runs during rendering. So, if you need to call a function with a side-effect then use the useEffect hook.

The purpose of this hook is to improve performance when a function is computationally expensive. It is NOT to be used on all your functions.

# What to do this week

TODO Things to do before next week.

Last Updated: 4/9/2024, 8:56:38 PM