Hyperjump Web Framework (WIP)Hyperjump Web Framework (WIP)
Guides

React

Prerequisite

Before reading this guide, you should be familiar with React. Please read every single page in the React documentation.

In this page we lay out some conventions to follow when developing a React application in Hyperjump so that the code is easier to understand and test.

Do not use useEffect unnecessarily

One of the most common newbie mistakes when using React is to use useEffect unnecessarily. There's even a page in React documentation that explains that you might not need an Effect. Please read it thoroughly.

Let's see a bad example:

component.tsx
const Component = () => {
  const [data, setData] = useState<string[]>([]);
  const [message, setMessage] = useState<string>("");

  useEffect(() => {
    setMessage(`Hello, ${data.join(", ")}!`);
  }, [data]);

  const handleClick = () => {
    fetch("https://api.example.com/data")
      .then((response) => response.json())
      .then((data) => {
        setData(data);
      });
  };

  return (
    <div>
      <p>{message}</p>
      <button onClick={handleClick}>Fetch Data</button>
    </div>
  );
};

In that example, the message state and the effect to update the message are not necessary (line 3-7). The message variable can be computed from the data state using a simple expression as follows:

component.tsx
const Component = () => {
  const [data, setData] = useState<string[]>([]);

  const message = `Hello, ${data.join(", ")}!`;

  const handleClick = () => {
    fetch("https://api.example.com/data")
      .then((response) => response.json())
      .then((data) => {
        setData(data);
      });
  };

  return (
    <div>
      <p>{message}</p>
      <button onClick={handleClick}>Fetch Data</button>
    </div>
  );
};

Rule of thumb

If a "state" can be computed, do not use Effect to update it.

Always encapsulate the logic in a custom hooks

If you look at a React component, e.g., the component.tsx in the previous example, we can actually see that it has two parts:

  • The code that constructs the UI. This is the JSX code that is returned by the component.
  • The logic that is used to update the state and run effects. This is every line of code from the opening curly brace { to the return statement.

As experience has shown, when we have so many lines of code in the logic part, it is difficult to understand and test. Imagine if we have a component with 100 lines of code in the logic part. It takes more time and effort to even find where the UI part is.

In the example above, we can encapsulate the logic in a custom hook as follows:

use-data.ts
const useData = () => {
  const [data, setData] = useState<string[]>([]);

  const fetchData = useCallback(() => {
    fetch("https://api.example.com/data")
      .then((response) => response.json())
      .then((data) => {
        setData(data);
      });
  }, []);

  const message = `Hello, ${data.join(", ")}!`;

  return useMemo(
    () => ({ data, fetchData, message }),
    [data, fetchData, message]
  );
};

Good to know

The name of the custom hook has to be prefixed with use. This will help react's hook linter to lint the custom hooks to follow the rule of hooks.

Good to know

Once React compiler is stable and available by default in Next.js, we won't need the useCallback and useMemo anymore.

Then we can use the custom hook in the component as follows:

component.tsx
const Component = () => {
  const { data, fetchData, message } = useData();

  return (
    <div>
      <p>{message}</p>
      <button onClick={fetchData}>Fetch Data</button>
    </div>
  );
};

As you can see, the component is now much simpler and easier to understand. The logic is encapsulated in the custom hook and the component is only responsible for the UI. It is also easier to understand the logic. We understand immediately from its name (useData) that the custom hook is to fetch the data, and simply return both the data and the message.

Rule of thumb

A React component must not have any useState and useEffect statements. All the state management and effect handling should be encapsulated in custom hooks.

Do not create inline functions as component

Another common mistake that makes a component hard to understand is to create inline functions that return JSX. Let's see a bad example:

tabs.tsx
const Tabs = ({ title }: { title: string }) => {
  const createTab = (label: string) => {
    return (
      <div>
        <p>
          {title}:{label}
        </p>
      </div>
    );
  };

  return (
    <div>
      {createTab("Tab 1")}
      {createTab("Tab 2")}
      {createTab("Tab 3")}
    </div>
  );
};

In that example, the createTab function pollutes the Tabs component. It has nothing to do with the Tabs component. Imagine if the function has hundreds of lines of code. It is difficult to understand what it is for.

The createTab function is essentially a React functional component since it returns JSX. It's so much better to define it as a separate component as follows:

tab.tsx
const Tab = ({ title, label }: { title: string; label: string }) => {
  return (
    <div>
      <p>
        {title}:{label}
      </p>
    </div>
  );
};

Then we can use the Tab component in the Component component as follows:

component.tsx
const Component = ({ title }: { title: string }) => {
  return (
    <div>
      <Tab title={title} label="Tab 1" />
      <Tab title={title} label="Tab 2" />
      <Tab title={title} label="Tab 3" />
    </div>
  );
};

Now, not only the Tabs component is much simpler and easier to understand, but also the Tab component is a separate component and it is easier to understand what it is for.

Rule of thumb

Do not create inline functions that return JSX. Instead, create a named function and use it.

Use HOC aggresively

Concretely, a higher-order component (HOC) is a function that takes a component and returns a new component. This is another way to keep the components clean and modular just like the custom hooks.

Let's see a very simple example. Say we have a component that displays a "Logout" button when the user is logged in and a "Login" button when the user is not logged in. Usually, we would write a component like this:

auth-button.tsx
const AuthButton = () => {
  const isLoggedIn = useIsLoggedIn();
  return (
    <div>{isLoggedIn ? <button>Logout</button> : <button>Login</button>}</div>
  );
};

We already use a custom hook, which is great! But the problem with this approach is that the AuthButton component still needs to actively "get" a piece of data (isLoggedIn in this example) to correctly render the component. We can make this component dumber simpler by removing the custom hook call and instead pass the isLoggedIn data as a prop to the component.

auth-button.tsx
const AuthButton = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
  return (
    <div>{isLoggedIn ? <button>Logout</button> : <button>Login</button>}</div>
  );
};

Now the AuthButton component only focuses on rendering the buttons conditionally. But how do we get the isLoggedIn data? We can create a higher-order function to do this.

with-auth.tsx
import { useIsLoggedIn } from "../hooks/use-is-logged-in";
import type { ComponentProps } from "react";

export const withAuth = <T extends ComponentProps<"div">>(
  Component: React.ComponentType<T & { isLoggedIn: boolean }>
) => {
  const WithAuth = (props: T) => {
    const isLoggedIn = useIsLoggedIn();
    return <Component {...props} isLoggedIn={isLoggedIn} />;
  };
  type WithoutAuthProps = Omit<T, "isLoggedIn">;
  return WithAuth as React.ComponentType<WithoutAuthProps>;
};

Then we can use the withAuth higher-order function to wrap the AuthButton component as follows:

component.tsx
export default withAuth(AuthButton);

The great thing about this approach is that we can now create another new component that needs isLoggedIn data as a prop by simply wrapping the component with the withAuth higher-order function.

Prevent impossible states

The following is a bad code. Can you tell why?

component.tsx
const MyComponent = () => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(false);
  const [success, setSuccess] = useState(false);

  return (
    <div>
      {loading && !error && !success && <p>Loading...</p>}
      {error && !loading && !success && <p>Error occurred</p>}
      {success && !loading && !error && <p>Operation completed successfully</p>}
    </div>
  );
};

The code above is bad because it doesn't prevent impossible states from happening. For example, loading can be true while success is true. What we want is actually an exclusive state machine: of all three states, only one can be true at any given time, like this:

component.tsx
import { useState } from "react";

type State = "loading" | "error" | "success";

const useStateMachine = () => {
  const [state, setState] = useState<State>("loading");

  const handleClick = () => {
    setState("loading");
    // Simulate an async operation
    setTimeout(() => {
      setState("success");
    }, 2000);
  };

  return { state, handleClick };
};

const MyComponent = () => {
  const { state, handleClick } = useStateMachine();

  return (
    <div>
      {state === "loading" && <p>Loading...</p>}
      {state === "error" && <p>Error occurred</p>}
      {state === "success" && <p>Operation completed successfully</p>}
      <button onClick={handleClick}>Click me</button>
    </div>
  );
};

Controlled and uncontrolled components in a form

It is very common to use a state to store the value of an input in a form like this:

component.tsx
const MyComponent = () => {
  const [value, setValue] = useState("");

  return (
    <input
      type="text"
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
};

Now imagine we have 10 or more inputs in the form. We will have to create 10 or more state variables to store the value of each input. It could get messy quickly.

When your input field has value property and onChange handler, it becomes a controlled component. Before turning inputs in a form into controlled components, you should ask yourself if you need it to be controlled because adding states adds complexity, introduces potential bugs, more code in the bundle, and more CPU cycles.

These are some cases where you think you need to use controlled inputs but you might not need it.

Collecting input values

One of the common cases to use controlled inputs is to collect the input values and send to an API route like this:

component.tsx
const MyComponent = () => {
  const [value, setValue] = useState("");

  const handleSubmit = async () => {
    await fetchData({
      body: { value },
    });
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={value}
          onChange={(e) => setValue(e.target.value)}
        />
        <button type="submit">Submit</button>
      </form>
    </div>
  );
};

However, if we are using a server function to handle the form submission, we don't need to use controlled inputs.

component.tsx
import { useActionState } from "react";
import { serverFunction } from "./some-server-function";

const MyComponent = () => {
  const [state, action, pending] = useActionState(serverFunction, null);
  return (
    <div>
      <form action={action}>
        <input type="text" name="username" />
        <button type="submit">Submit</button>
      </form>
    </div>
  );
};
some-server-function.ts
export const serverFunction = async (formData: FormData) => {
  const username = formData.get("username");
  return { username };
};

As you can see, we just need to make sure that every input field has a name attribute so that the server function can extract the value of the input field from the form data.

Good to know

For more information on using server functions, please refer to the Updating Data guide.

Input validation

Another common case to use controlled inputs is to validate the input values before sending data to the server. Here's some things you should consider before turning your input fields into controlled components.

  1. Your server side code, whether it's a server function or a route handler, will validate the input values anyway because data from external sources must always be validated. So consider if it's acceptable to validate the inputs in the server and simply show the validation errors in the client if any.
  2. The modern browser has built-in validation for input fields. For example, if you use the required attribute, the browser will show a validation error if the input field is empty. Of if you add type="email", the browser will show a validation error if the input field is not a valid email address. Consider the built-in HTML attributes like pattern, minLength, required, input type, etc.
  3. For validation rules that are not supported natively by the browser, consider using useResettableActionState to validate the input values before data is sent to a server function. For example, you might want to make sure two input fields have the same value (e.g., password and confirm password) like this:
component.tsx
"use client";
import { doSomething } from "./actions";
import { useResettableActionState } from "use-resettable-action-state";

export default function Form({
  initialState,
}: {
  initialState: { password: string | null; error: string | null };
}) {
  const [state, submit, isPending, reset, payload] = useResettableActionState(
    doSomething,
    initialState,
    undefined,
    async (payload, abortController) => {
      if (payload?.get("password") !== payload?.get("repeat-password")) {
        abortController.abort({
          error: "Passwords do not match",
        });
      }
      return payload;
    }
  );

  return (
    <form action={submit}>
      {state && !state.error && <p>Success!</p>}
      {state && state.error && (
        <p className="bg-red-500 text-white p-4">{state.error}</p>
      )}
      <input
        type="password"
        name="password"
        id="password"
        placeholder="Enter new password"
      />
      <input
        type="password"
        name="repeat-password"
        id="repeat-password"
        placeholder="Repeat the new password"
      />
      <p>{state && state.data?.message}</p>

      <button disabled={isPending} type="submit">
        {isPending ? "Loading..." : "Submit"}
      </button>
    </form>
  );
}

Use controlled inputs when all three conditions above are not met.

Using third party form libraries

There are many third party form libraries that can help you manage forms in a more declarative way. For example, React Hook Form and Formik.

TODO

Discuss and write down when to use third party form libraries.

Compound pattern

The compound pattern is a way to create a component that is a composition of multiple components. It is a way to keep the components clean and modular. Let's say we want to create a Tabs component. It consists of two main parts:

  • The list of tabs: it shows all the available tabs, which tab is currently active (selected), and allows the user to switch between tabs.
  • The content of the tabs: it shows the content of the currently active tab.

Without compound pattern

A straightforward approach is to create a single component that handles both the list of tabs and the content of the tabs.

tabs.tsx
const Tabs = ({
  tabs,
}: {
  tabs: {
    key: string;
    label: string;
    content: React.ReactNode;
    isActive: boolean;
  }[];
}) => {
  const [activeTab, setActiveTab] = useState(
    tabs.find((tab) => tab.isActive)?.key
  );
  return (
    <div className="flex flex-col gap-2">
      <div className="flex flex-row gap-2">
        {tabs.map((tab) => {
          const isActive = tab.key === activeTab;
          return (
            <button
              key={tab.key}
              onClick={() => setActiveTab(tab.key)}
              className={cn(
                isActive ? "bg-blue-500 text-white" : "bg-gray-500 text-white"
              )}
            >
              {tab.label}
            </button>
          );
        })}
      </div>
      <div>{tabs.find((tab) => tab.key === activeTab)?.content}</div>
    </div>
  );
};

Then we can use the Tabs component like this:

component.tsx
const Component = () => {
  return (
    <Tabs
      tabs={[
        {
          key: "tab1",
          label: "Tab 1",
          content: <div>Tab 1 content</div>,
          isActive: true,
        },
        {
          key: "tab2",
          label: "Tab 2",
          content: <div>Tab 2 content</div>,
          isActive: false,
        },
        {
          key: "tab3",
          label: "Tab 3",
          content: <div>Tab 3 content</div>,
          isActive: false,
        },
      ]}
    />
  );
};

The problem with this approach is that the Tabs component is getting too complex and it has no separation of concerns. It is responsible for both the list of tabs and the content of the tabs. It is also responsible for the state management of the active tab. It is also responsible for the logic to switch between tabs. It simply does too much.

With compound pattern

We can use the compound pattern to break down the Tabs component into smaller components that are responsible for a single responsibility:

  • The Tabs component: the parent component that manages the state and delegates rendering to the child components.
  • The TabList component: the component that renders the list of tabs.
  • The Tab component: the component that renders a single tab.
  • The TabPanels component: the component that renders the content of the tabs.
  • The TabPanel component: the component that renders the content of the currently active tab.
tabs.tsx
// Create a context to share state between components
const TabsContext = createContext(null);

// Parent component manages state but delegates rendering
const Tabs = ({ children, defaultActiveKey }) => {
  const [activeKey, setActiveKey] = useState(defaultActiveKey);

  return (
    <TabsContext.Provider value={{ activeKey, setActiveKey }}>
      <div className="tabs-container">{children}</div>
    </TabsContext.Provider>
  );
};

// Child components with default styling
const TabList = ({ children, className = "flex flex-row gap-2" }) => {
  return <div className={className}>{children}</div>;
};

const Tab = ({ children, tabKey, disabled, className }) => {
  const { activeKey, setActiveKey } = useContext(TabsContext);
  const isActive = activeKey === tabKey;

  const defaultClassName = isActive
    ? "bg-blue-500 text-white px-4 py-2 rounded"
    : "bg-gray-500 text-white px-4 py-2 rounded";

  return (
    <button
      disabled={disabled}
      onClick={() => !disabled && setActiveKey(tabKey)}
      className={className || defaultClassName}
    >
      {children}
    </button>
  );
};

const TabPanels = ({ children, className = "mt-4" }) => {
  return <div className={className}>{children}</div>;
};

const TabPanel = ({ children, tabKey }) => {
  const { activeKey } = useContext(TabsContext);

  if (activeKey !== tabKey) return null;
  return <div className="tab-panel">{children}</div>;
};

// Attach child components to the parent
Tabs.TabList = TabList;
Tabs.Tab = Tab;
Tabs.TabPanels = TabPanels;
Tabs.TabPanel = TabPanel;

Then we can use the Tabs component with its default styling like this:

component.tsx
<Tabs defaultActiveKey="tab1">
  <Tabs.TabList>
    <Tabs.Tab tabKey="tab1">First Tab</Tabs.Tab>
    <Tabs.Tab tabKey="tab2">Second Tab</Tabs.Tab>
    <Tabs.Tab tabKey="tab3" disabled>
      Disabled Tab
    </Tabs.Tab>
  </Tabs.TabList>

  <Tabs.TabPanels>
    <Tabs.TabPanel tabKey="tab1">
      <p>Content for first tab</p>
    </Tabs.TabPanel>
    <Tabs.TabPanel tabKey="tab2">
      <p>Content for second tab</p>
    </Tabs.TabPanel>
    <Tabs.TabPanel tabKey="tab3">
      <p>Content for disabled tab</p>
    </Tabs.TabPanel>
  </Tabs.TabPanels>
</Tabs>

Or we can have Tabs component with custom styling like for the tabs list and the panels:

component.tsx
<Tabs defaultActiveKey="tab1">
  <Tabs.TabList className="flex flex-col space-y-2 border-r pr-4">
    <Tabs.Tab
      tabKey="tab1"
      className="text-left hover:bg-gray-100 px-4 py-2 rounded-l"
    >
      First Tab
    </Tabs.Tab>
    <Tabs.Tab
      tabKey="tab2"
      className="text-left hover:bg-gray-100 px-4 py-2 rounded-l"
    >
      Second Tab
    </Tabs.Tab>
  </Tabs.TabList>

  <Tabs.TabPanels className="pl-4 flex-1">
    <Tabs.TabPanel tabKey="tab1">
      <p>Content for first tab</p>
    </Tabs.TabPanel>
    <Tabs.TabPanel tabKey="tab2">
      <p>Content for second tab</p>
    </Tabs.TabPanel>
  </Tabs.TabPanels>
</Tabs>

With compound pattern, we have a clear separation of concerns and more flexibility.

Rule of thumb

Always aim to keep the component simple with minimum responsibility. Use the compound pattern to further break down the component into smaller components that are responsible for a single responsibility.

Ground Rules

Action
Don't use useEffect unnecessarily. Compute the value if possible.
Always encapsulate the logic in a custom hook.
Do not create inline functions that return JSX. Instead, create a separate component.
Use HOC to further reduce the complexity of a component.
Be cautious about adding states to a component.
Use uncontrolled inputs in forms when possible.
Use compound pattern to have a clear separation of concerns and more flexibility.

Todos

  • Search for or create a linter rule to enforce the rule: "A React component must not have any useState and useEffect statements. All the state management and effect handling should be encapsulated in custom hooks." Doc: React
  • Search for or create a linter rule to enforce the rule: "Do not create inline functions that return JSX. Instead, create a separate component." Doc: React