React Router with data loading - vonschappler/Ultimate-React GitHub Wiki

Planning your application:

When planning small apps with only one page and few feature, planning and building an application consists in basicaly 3 steps:

  1. Break the desired UI into components
  2. Desing and build a static version (without state)
  3. Think about state management + data flow

For real world large application some adaptation is required, which leads us to:

  1. Gathering the application requirements and features
  2. Divide the application into pages
    1. Think about the overall and page-level UI
    2. Break the desired UI into components
    3. Design and build a static version (wihout state)
  3. Divide the application into feature categories
    1. Think about state management + data flow
  4. Decide on what libraries to use (technology decisions, where we decide in which tech the application will be based on)

The steps listed above is a rough overview, because as we might expect, in real life, nothing goes that linear, but this overview might help in the process of creating an application.


The steps on details

Using the application built in the section a online pizza order service as an example, we are going to break in the steps mentioned above, so it should help to make easier to understand what each step means.

Gathering the application requirements (step 1):

This processes consists in colleting the "clients" needs on the aaplication. On our example, our client requested that:

  1. The application to be very simple - the users can order one or more pizzas from a menu
  2. The applicatio does not require any user accounts or login - the users simply put their names before using the app
  3. The pizza menu can be change, so it should be loaded from an API (which on this case was already done)
  4. Users can add multiple pizzas to a cart before ordering
  5. Ordering requires only the user's name, phone number and address
  6. If possible, GPS location should be provided, to make delivery easier
  7. User's can mark their order as "priority" for an additional 20% of the cart price
  8. Orders are made by sending a POST request with the order data (user data + selected pizzas) to the API
  9. Paymentas are made only on delivery, so no payment processing is necessary in the APP
  10. Each order will get a unique ID that should be displayed, so the user can later look up their order based on that ID
  11. Users should be able to mark their order as "priority" even after the order has been placed

From those requirements, even though it's not possible to simply build the accplication, it's possible to think about the necessary features and pages needed by this application.

Dividing the application and pages and features (steps 2 and 3):

After gathering all the information from the (virtual client), it's time to start thinking on how the application will be organized/divided. From the list of requirements desided by the client, it's possible to organize the application in the set of features below.

  • Feature categories

    • User
    • Menu
    • Cart
    • Order

      Note that all features in the application can be place in one of those derived features from the information gathered above, because this is in essense what the application will be about.

  • Necessary pages

    • Home page (/)
    • Pizza menu Page (/menu)
    • Cart Page (/cart)
    • Placing a new order page (/order/new)
    • Looking up an order page (/order/:orderID)

When we compare the features categories and the list of necessary pages, we can see how the categories helps us to define which pages we need on our application. That is so, because:

Feature Page
User Homepage (the user will enter their infomation here)
Menu Pizza menu
Cart Cart page
Order Placing an order
Looking up an order

State management (step 3 - continued):

When we look at the features categories, we can clearly see that they match pretty well with the concept of "state domains" or "state slices". So, basically - in this example, there is a global state, and one slice is the User, the other is the Menu and so on.

But the way the state management will be applied on the application will depend on how we classify each one of the possible "slices" of state.

In this case, the best type classification for the states can be:

  1. User: global UI state (no accounts, so it stays in the application, but it needs to be accessed by other parts of the application)
  2. Menu: global remote state (the menu is going to be fetched from an API)
  3. Cart: global UI state (the api doesn't need the cart, so it's stored simply in the application)
  4. Order: global remote state (the order will be posted to the API)

So basically, the Menu and the Order are remote states, because they "live" inside a API server, while the other two states are UI states, because there no need to pass them to the world outside the application.

Being able to dicern the types of states help us to chose the libraries/technologies which are going to be used during the development of the application. Also remember ths in real life most of the time these decisions are not taken easly during this stage, but mostly when the aplication is already being created.

Taking the Technical decisions (step 4):

Based on all the information gathered from the previous steps, it's time to decide which technologies will be used to build the application. We can organize them into "categories" and for this specific case we can use:

Category Technology Reason
Routing React Router Standard for React SPAs
Styling tailwindcss Trendy way of styling applications
Remote State Management React Router New way of fetching data inside React Router - by using the "render-as-you-fetch" approach

NOTE:This is not really a state management state strategy as it does not persist state on the application
UI State Management Redux State is fairly complex, giving Redux a bit of advantage in this particular case

Structuring the Application

For this project, the preferred file structure was to organize the application into features, so instead of a HUGE FOLDER with all the components, the components and their sungular features will be organized per-feature.

Taking this approach allows better coding, because all the correlated files will be centralized, removing the necessicity of "jumping" around the folder structure when coding the application.

It's important to rember that our application may contain some reusable components, and for those, it's advised that we store them inside a folder named UI (or any other name we desire, since this folder is inteded to store reusable components just as buttons, input fields, etc.)

Any reusable code that communicate with an API also can be stored in a separeted folder, usually named "services".

For resusable code which does not create any side effects, we can also create a separated folder. The files sotred in this folder are usually "utility" files, hence the reason why this folder is conventionally named "utils".


Fetching data with React Router 6.4

One of the nice features of React Router is the possibility to fetch and post data to forms. In order to do so, after installing the "react-router-dom" package, it's necessary to define our routes using the imported method createBrowserRouter(), which receives as arguments an array of objects where each object makes reference to a route.

The snippet of code below shows how to create and use the method mentioned previously.

// file App.jsx

// other imports
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Component1 from './path/to/Component1';
import Component2 from './path/to/Component2';

// some other code here

const router = createBrowserRouter([
  {
    path: '/component/path',
    element: <Component1 />,
  },
  {
    path: 'component2/path',
    element: <Component2 />,
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

export default App;

This new way of working with routers is required to enable the data fetching capabilities of React Router.

To create nested nested routes, the way we declare the router variable is a bit different as displayed below:

// some code here

const router = createBrowserRouter([
  {
    element: <Component />,
    children: [
      {
        path: 'nested-route/component1/path',
        element: <Component1 />,
      },
      {
        path: 'nested-route/component2/path',
        element: <Component2 />,
      },
    ],
  },
]);

// some more coee here

And for that to work properly, it's also necessary to make use of the functional component <Outlet /> on our code, insde the component which will re-render the components from the next router.

// file Component.jsx
import { Outlet } from 'react-router-dom';

function Component() {
  return <Outlet />;
}

export default Component;

Technically, when we nest routes using React Router (as done above with the nested routes) and the parent component does not have a path, this is called layout route.


Loaders

The concept of loaders was introduced in React Router 6.4. With this concept it's possible to fetch some specific data within routes, as soon as our application reaches a route which requires data fetching.

In our code we define some fetching funcions, called "loader functions" to then pass those functions inside the router variable, enabling routes defined there to access one or more loader functions, which will fetch data when/if necessary, which will will then provide the date loaded to the component which needs to have access to the fetched data.

By convension, these loader functions are created inside the page/component file which will require that data fetching.

The snippets of code below examplifies how to work with those loader:

// file services/api.js

// some code here
export async function getData() {
  const res = await fetch(`path/to/api`);

  if (!res.ok) throw Error('Failed getting data');

  const { data } = await res.json();
  return data;
}

export async function getDataWithParams(param) {
  const res = await fetch(`path/to/api/${param}`);

  if (!res.ok) throw Error('Failed getting data');

  const { data } = await res.json();
  return data;
}

// some more code here
// file ComponentWithFetching.jsx
import { useLoaderData } from 'react-router-dom';
import { getData, getDataWithParams } from 'path/to/services/api';

function ComponentWithFetching() {
  const data = useLoaderData();
  return (
    // this is just an example of what can be done with the fetched data.
    <ul>
      {data.map((item) => (
        return <li key={item.uniqueValue}>{JSON.stringify(item)} </li>;
      ))}
    </ul>
  );
}

// By convension, the loader function is called loader
export async function loader() {
  const data = await getData();
  return data;
}

export async function loaderWithParams({parmams}) {
  const data = await getDataWithParams(params.value);
  return data;
}

export default ComponentWithFetching;
// file App.jsx

// some code here

// It may be necessary to rename the loader during the import in cases where many loaders are going to be required in different components
import ComponentWithFetching, {
  loader as customLoader,
  loaderWithParams as customLoaderWithParams,
} from 'path/to/ComponentWithFetching';

const router = createBrowserRouter([
  {
    element: <Component />,
    children: [
      {
        path: 'nested-route/component1/path',
        element: <Component1 />,
      },
      {
        path: 'nested-route/component2/path',
        element: <Component2 />,
      },
      {
        path: 'nested-route/fetch-component/path',
        element: <ComponentWithFetching />,
        loader: customLoader,
      },
      {
        path: 'nested-route/fetch-component/path/:value',
        element: <ComponentWithFetching />,
        loader: customLoaderWithParams,
      },
    ],
  },
]);

// some more code here

Error Handling

In order to handle errors using the new features of React Router, which will render an "Error component", we need to specify that component on the router definition, as displayed below. Notice that we can place the errorElement property for the router either on a parent component or a child component.

Just keep in mind that if the errorElement property is placed only in the parent component for the case of nested routes, all the application will be replaced by that the component rendered on errors, instead of inside the base layout component provided by the parent layout route.

// some code here
import ErrorComponent from 'path/to/ErrorComponent';

const router = createBrowserRouter([
  {
    element: <Component />,
    errorElement: <ErrorComponent />,
    children: [
      {
        path: 'nested-route/component1/path',
        element: <Component1 />,
      },
      {
        path: 'nested-route/component2/path',
        element: <Component2 />,
      },
      {
        path: 'nested-route/fetch-component/path',
        element: <ComponentWithFetching />,
        loader: customLoader,
        errorElement: <ErrorComponent />,
      },
      {
        path: 'nested-route/fetch-component/path/:value',
        element: <ComponentWithFetching />,
        loader: customLoaderWithParams,
      },
    ],
  },
]);
// some more code here

Sending data to the server

Actions allows us to write or mutate data on servers, using actions frunctions and forms which are linked to the routes, in a way similar to what is done with loaders.

In order to make use of this feature, both a form and an action should be added to the component(s) which can send data to a server.

The snippets of code below is a simple example on how to use this implementation for sending data to a server using React Router 6.4:

// file services/api.js

// some code here
export async function postData(formData) {
  try {
    const res = await fetch(`path/to/api/`, {
      method: 'POST',
      body: JSON.stringify(formData),
      headers: {
        'Content-Type': 'application/json',
      },
    });

    if (!res.ok) throw Error();
    const { data } = await res.json();
    return data;
  } catch {
    throw Error('Failed posting data');
  }
}

// some more code here
// file ComponentWithForm.jsx

import { Form, redirect } from 'react-router-dom';
import { postData } from 'path/to/services/api';

function ComponentWithForm() {
  return (
    <div>
      {/* it's optional to set the action attribute, because React Router will match it automatically, as long as this structure is followed */}
      {/* <Form method='POST' action='/path/to/ComponentWithForm/route'> */}
      <Form method='POST'>
        <div>
          <label>Input Label</label>
          <input type='text' name='input' required />
        </div>
        <div>
          {/* this can be used in case we wish to insert on the formData some hidden infornation */}
          <input type='hidden' name='hiddenField' />
          <button>Submit</button>
        </div>
      </Form>
    </div>
  );
}

export async function action({ request }) {
  // these two lines will remain the same, unless something changes on the browser API, since they are Browser API related methods
  const formData = await request.formData();
  const data = Object.fromEntries(formData);

  // any logical processing of the data should be done here
  const newData = await postData(data);

  // this is used if we wish to redirect the user to a page overview of the newly created data
  return redirect(`/nested-route/fetch-component/path/${newData.value}`);
}

export default ComponentWithForm;
// file App.jsx
// some code here
import ComponentWithForm, {
  action as customPostData,
} from 'path/to/ComponentWithForm';

const router = createBrowserRouter([
  {
    element: <Component />,
    errorElement: <ErrorComponent />,
    children: [
      {
        path: 'nested-route/component1/path',
        element: <Component1 />,
      },
      {
        path: 'nested-route/component2/path',
        element: <Component2 />,
      },
      {
        path: 'nested-route/fetch-component/path',
        element: <ComponentWithFetching />,
        loader: customLoader,
        errorElement: <ErrorComponent />,
      },
      {
        path: 'nested-route/fetch-component/path/:value',
        element: <ComponentWithFetching />,
        loader: customLoaderWithParams,
      },
      {
        path: 'nested-route/post-component/path/',
        element: <ComponentWithForm />,
        action: customPostData,
      },
    ],
  },
]);

// some more code here

Error handling with actions

Error handling in actions are made inside the action that submits the form data to the server. Errors can be triggered so a form is valdiaded before being submitted to the server, preventing errors when inserting new data into a server.

This is made by adding an errors object inside the action function, with the standards of what the form should expect as inputs to the fields and what the server should expect as processed data sent by the action.

The snippets of code below are an example of how to work with this error handling technique, using React Router 6.4 :

// file ComponentWithForm.jsx

// file ComponentWithForm.jsx

import { Form, redirect, useNavigation, useActionData } from 'react-router-dom';
import { postData } from 'path/to/services/api';

function ComponentWithForm() {
  const navigation = useNavigation();
  const isSubmitting = navigation.state === 'submitting';
  const formErrors = useActionData();
  return (
    <div>
      {/* it's optional to set the action attribute, because React Router will match it automatically, as long as this structure is followed */}
      {/* <Form method='POST' action='/path/to/ComponentWithForm/route'> */}
      <Form method='POST'>
        <div>
          <label>Input Label</label>
          <input type='text' name='input' required />
        </div>
        <div>
          {/* this can be used in case we wish to insert on the formData some hidden infornation */}
          <input type='hidden' name='hiddenField' />
          <button disabled={isSubmitting}>
            {isSubmitting
              ? `Please wait while your data is being processed...`
              : `Submit form`}
          </button>
        </div>
      </Form>
    </div>
  );
}

export async function action({ request }) {
  // these two lines will remain the same, unless something changes on the browser API, since they are Browser API related methods
  const formData = await request.formData();
  const data = Object.fromEntries(formData);
  // any logical processing of the data should be done here

  const errors = {};
  // note that here in this example, we are using a general validation condition instead of declaring a specific one, because any valid validation logic can be inserted here
  if (validationCondition) {
    errors.validadionFieldReason = `Error message to be rendered`;
  }

  if (Object.keys(errors).length > 0) return errors;

  const newData = await postData(data);

  // this is used if we wish to redirect the user to a page overview of the newly created data
  return redirect(`/nested-route/fetch-component/path/${newData.value}`);
}

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