Starting a React Project Today

Choosing a React framework and writing clearer code by removing unnecessary Effects.

Published
February 2, 2024
日本語で読む

Static hosting on CloudFront and S3 was a requirement, so we picked the Next.js App Router and left Server Actions unused. Choosing a framework is a sequence of trades like that one.

This began as a talk, rebuilt here as an article. It covers two decisions: how to choose the foundation for a new project, and how to make React code easier to read by treating useEffect as an escape hatch.

How should a project start?

A React project can be assembled from a build tool such as Vite or Parcel, or it can begin with a framework such as Next.js or React Router.

The React documentation recommends a framework for a new application or site. Starting from a build tool remains appropriate when adding React to an existing page, learning the library, or working under requirements that existing frameworks do not satisfy.

Routing, data loading, code splitting, and rendering strategy become difficult to combine one by one after an application has grown. A suitable framework can provide those integrations. The choice should still come from the delivery model of the product, its server requirements, and the team that will operate it, rather than from a recommendation read in isolation.

Why we chose the App Router in 2024

The project I discussed in the original talk required static hosting on CloudFront and S3. Next.js static export and the layout model in the App Router made it a useful fit. Static export is still supported, but server-dependent features such as Server Actions cannot run in that deployment model.

We used the App Router for routing, layouts, and the build process while keeping data fetching in Client Components with SWR. We deliberately did not adopt async Server Components or Server Actions because they did not fit the static-output requirement and had less operational history at the time.

This was not a claim that every Next.js project should work this way. It was a deliberate distance from the parts of the framework that the product did not need.

Validate the surrounding toolchain

In 2024, we ran into integration problems between our App Router setup and MSW. Instead of assuming that every familiar tool would work unchanged, we created a small alternative for development: an SWR middleware that returned data for known keys.

interface MockData {
  key: string;
  data: unknown;
}

const mockData: MockData[] = [];

export const testMiddleware: Middleware = () => {
  return (key): SWRResponse => ({
    data: mockData.find((mock) => mock.key === key)?.data,
    error: undefined,
    mutate: () => Promise.resolve(),
    isValidating: false,
    isLoading: false,
  });
};

This does not mock the network and therefore is not equivalent to MSW. It was sufficient for rendering loading, error, and success states while the API integration was tested elsewhere.

The specific compatibility issue is historical; Next.js and MSW have both changed. The lasting lesson is to test the intended framework, deployment target, test tools, and development tools together in a small proof of concept.

A framework is a set of capabilities

It helps to separate what the project needs from a framework and what it needs from React. We wanted routing, layouts, and a build process from Next.js. At that point, we wanted stable component patterns from React rather than every Canary feature.

Evaluate the capabilities that matter: static or server deployment, self-hosting, navigation events, URL and history behavior, caching, and the amount of Server Component architecture the team wants to own. A framework is useful when that combination matches the product, even if the project does not use every feature it offers.

Why write good code?

Developers spend a large part of their time reading code: reviewing pull requests, tracing an existing behavior, and finding the right place to change. Good code is written for those readers.

Comments and focused pull requests matter too, but here I will narrow the discussion to React and useEffect. React code becomes easier to follow when an Effect is used only for synchronization with something outside React.

Remove unnecessary Effects

An event handler makes its trigger visible: clicking this element runs this function. An Effect runs after rendering and runs again when one of its reactive dependencies changes. Understanding the trigger may require tracing where each dependency changes across the component tree.

That does not make Effects bad. It makes their purpose specific. The React documentation covers the same ground in more detail.

You Might Not Need an Effect – ReactThe library for web and native user interfacesreact.dev

What an Effect is for

An Effect synchronizes a component with an external system. A browser subscription is a simple example.

useEffect(() => {
  const handler = () => {
    // Respond to a resize.
  };

  window.addEventListener("resize", handler);
  return () => {
    window.removeEventListener("resize", handler);
  };
}, []);

The listener exists outside React and should exist only while the component needs it. The cleanup mirrors the setup.

A chat connection has the same shape.

useEffect(() => {
  const connection = createConnection();
  connection.connect();

  return () => {
    connection.disconnect();
  };
}, []);

Sending a message because someone clicked Submit belongs in the event handler. Keeping a connection synchronized with the presence of the component belongs in an Effect.

Do not store derived state

If a value can be calculated from props or state during rendering, an Effect is usually unnecessary.

const [firstName, setFirstName] = useState("Taylor");
const [lastName, setLastName] = useState("Swift");
const [fullName, setFullName] = useState("");

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

fullName is derived from two existing states. Calculate it directly and avoid both an extra render and the possibility of stale state.

const [firstName, setFirstName] = useState("Taylor");
const [lastName, setLastName] = useState("Swift");

const fullName = `${firstName} ${lastName}`;

If the calculation is genuinely expensive, memoize it based on the values that affect it.

const fullName = useMemo(
  () => calculateFullName(firstName, lastName),
  [firstName, lastName],
);

Use library APIs that express the event

Suppose SWR loads initial values for a React Hook Form form. An Effect can copy the response into each field, but a library callback may express the sequence more directly.

const methods = useForm<FormSchema>();

useSWR("/wines/reds", fetcher, {
  onSuccess: (data) => {
    methods.reset(data);
  },
});

The reader can see that a successful request resets the form. Depending on the desired behavior, the values or defaultValues options in React Hook Form may be a better fit. They differ in how they treat later data changes and dirty fields, so choose the one that matches the product rather than removing an Effect mechanically.

Use useSyncExternalStore for external stores

When React needs to read a changing value from an external store, useSyncExternalStore separates subscription from snapshot reading.

const subscribe = (callback: VoidFunction) => {
  window.addEventListener("resize", callback);
  return () => window.removeEventListener("resize", callback);
};

const getSnapshot = () => window.innerWidth;
const getServerSnapshot = () => 0;

const width = useSyncExternalStore(
  subscribe,
  getSnapshot,
  getServerSnapshot,
);

This communicates the relationship more precisely than combining a subscription Effect with separate state.

Write focused Effects

An Effect represents a synchronization cycle: start synchronizing, stop, and start again when a reactive dependency changes. Keep independent synchronization processes in separate Effects so that changing one dependency does not restart unrelated work.

Dependencies are not a list to tune for the desired frequency. They describe every reactive value used by the Effect. If some logic should not be reactive, move it outside the component, into the user event that causes it, or into an Effect Event where that API fits.

useEffectEvent is now stable. It can read the latest props and state from logic called by an Effect without forcing the Effect to reconnect for those values. It is not a way to hide real dependencies.

Closing note

The static-hosting decision from the opening and the design of an Effect land in the same place: use a tool for the responsibility it actually owns.

Choose a framework by deployment, routing, data, and operational needs. Use Effects to synchronize with systems outside React rather than to orchestrate every state transition. Clear boundaries make both the architecture and the code easier for the next person to read.

References