Contents
100 Chapter

Migrating Old React Code

A guide to moving old-style code — Class components, Pages Router, Redux-only, fetch-on-mount — into this book's modern style.

With Chapter 34, the main text of this book wrapped up. This appendix collects in one place the one area the main text deliberately did not cover: a procedure for moving old React code into this book’s modern style.

The main text, Chapters 1 ~ 34, teaches a single style from page one: function components + hooks, App Router, RSC + Server Actions, TypeScript first. Old styles (Class components, componentDidMount, Pages Router, Redux-only, fetch-on-mount, PropTypes) barely appear in the book. The judgment is that a single introductory book teaching two styles at once leaves no one fluent in either.

But in real codebases, the old style is alive. The goal of this appendix is to be a one-page map for readers facing such code. We will lay out two things: a line-by-line mapping from old to modern, and a procedure for moving large codebases without breakage.

Free chapter — this appendix is permanently free as part of the book’s free sample. Open it first when you have old React code in your hands and are trying to figure out where in the book to start. Once you find which chapter your code maps to in the mapping table, it becomes easy to see which part of the book to open.

Mapping — old style → this book #

The patterns you see most often in older code, and which chapter of this book picks each one up.

Old styleChapter in this book
class Foo extends React.ComponentChapter 4 (components and props) + Chapter 7 (state)
this.state / this.setStateChapter 7 (useState)
componentDidMount / componentDidUpdate / componentWillUnmountChapter 11 (useEffect)
componentDidCatchChapter 11 (ErrorBoundary section)
HOC / render propsChapter 12 (useContext) + Chapter 13 (custom hooks)
pages/foo.tsx (Pages Router)Chapter 23 (App Router)
getServerSideProps / getStaticPropsChapter 25 (RSC data fetching)
_app.tsx / _document.tsxChapter 23 (app/layout.tsx)
next/routerChapter 23 (next/navigation)
useEffect(() => { fetch(...) })Chapter 25 (direct fetch inside RSC)
Redux store / reducer / sagaChapter 24 (RSC) + Chapter 27 (Server Actions) + Chapter 12 (Context)
PropTypes.string.isRequiredChapters 16 ~ 17 (TypeScript)
styled-components / emotionNot opinionated in the main text (when CSS Modules / Tailwind is in use)
Trusting fetch responses as isChapter 21 (typing fetch and API responses)

This mapping is the spine of the appendix. We will unfold each row in the sections that follow.

Class component → function + hooks #

The most common transformation. It moves one component at a time, which makes it the best starting point for incremental migration.

this.state + this.setStateuseState #

old — Class
class Counter extends React.Component {
  state = { count: 0 };

  increment = () => {
    this.setState({ count: this.state.count + 1 });
  };

  render() {
    return (
      <button onClick={this.increment}>
        Clicks: {this.state.count}
      </button>
    );
  }
}
modern — function + useState
function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount((c) => c + 1)}>
      Clicks: {count}
    </button>
  );
}

The key difference is the trap in setState({ count: this.state.count + 1 }). Old code risks reading a stale value of this.state.count, which is why the this.setState((prev) => ...) callback form was recommended. With useState, the functional update setCount((c) => c + 1) plays the same role. The pattern from Chapter 7.

componentDidMountuseEffect(() => {...}, []) #

old
componentDidMount() {
  fetchUser(this.props.userId).then((u) => this.setState({ user: u }));
}
componentDidUpdate(prevProps) {
  if (prevProps.userId !== this.props.userId) {
    fetchUser(this.props.userId).then((u) => this.setState({ user: u }));
  }
}
componentWillUnmount() {
  this.cancelled = true;
}
modern
useEffect(() => {
  let cancelled = false;
  fetchUser(userId).then((u) => {
    if (!cancelled) setUser(u);
  });
  return () => { cancelled = true; };
}, [userId]);

Three lifecycles fold into one hook. Put userId in the dependency array and mount / update become one flow; the cleanup returned from the effect runs at both unmount and dependency change. The same model from Chapter 11 (useEffect).

That said, this book emphasizes avoiding fetch inside useEffect. The conversion above is just a 1:1 mapping — a better home is a direct fetch inside an RSC server component. We will revisit this in the “fetch-on-mount” section of this appendix.

componentDidCatch → ErrorBoundary component #

componentDidCatch is interestingly the only lifecycle that cannot be converted to a function-based form. React does not provide an equivalent as a hook. So an ErrorBoundary is still written as a class component.

ErrorBoundary — still a class
class ErrorBoundary extends React.Component<
  { fallback: React.ReactNode; children: React.ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    console.error('Caught by boundary:', error, info);
  }

  render() {
    if (this.state.hasError) return this.props.fallback;
    return this.props.children;
  }
}

Inside this book’s modern code too, you write the ErrorBoundary as a class component once and that is it. With App Router, app/error.tsx automatically plays the role of a per-route ErrorBoundary, so the need to write one by hand keeps shrinking.

HOC / render props → custom hook #

Two old-code patterns converge into a single modern custom hook.

old — HOC
const withUser = (Component) => (props) => {
  const [user, setUser] = useState(null);
  useEffect(() => { fetchCurrentUser().then(setUser); }, []);
  return <Component {...props} user={user} />;
};
const Profile = withUser(ProfileBase);
old — render props
function UserProvider({ render }) {
  const [user, setUser] = useState(null);
  useEffect(() => { fetchCurrentUser().then(setUser); }, []);
  return render(user);
}
<UserProvider render={(user) => <Profile user={user} />} />
modern — custom hook
function useCurrentUser() {
  const [user, setUser] = useState<User | null>(null);
  useEffect(() => { fetchCurrentUser().then(setUser); }, []);
  return user;
}

function Profile() {
  const user = useCurrentUser();
  // ...
}

The exact pattern from Chapter 14 (custom hooks). HOC wrapper hell and render-props callback nesting both disappear.

Pages Router → App Router #

This is typically the largest area of an old-codebase migration. Fortunately, Next.js officially supports the coexistence of /app and /pages, so you do not have to move it all at once.

Directory mapping #

Pages RouterApp Router
pages/index.tsxapp/page.tsx
pages/todos/[id].tsxapp/todos/[id]/page.tsx
pages/_app.tsxapp/layout.tsx (root)
pages/_document.tsx<html> / <body> inside app/layout.tsx
pages/api/foo.tsapp/api/foo/route.ts
pages/_error.tsx / pages/404.tsxapp/error.tsx / app/not-found.tsx

The biggest mental shift is one file = one route becoming one folder = one route + its special files. page.tsx / layout.tsx / loading.tsx / error.tsx / not-found.tsx live together in one folder. The same picture from Chapter 23.

getServerSideProps / getStaticProps → direct fetch inside RSC #

old — getServerSideProps
export async function getServerSideProps(context) {
  const { id } = context.params;
  const todo = await db.todos.findById(id);
  return { props: { todo } };
}

export default function TodoPage({ todo }) {
  return <article>{todo.title}</article>;
}
modern — RSC
export default async function TodoPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const todo = await db.todos.findById(id);
  return <article>{todo.title}</article>;
}

The page component itself becomes async, and data-fetching code moves inside the component. The separate exported function disappears, along with the props-serialization boundary. Two functions reduced to one is the most intuitive description of the change. The model from Chapter 25.

The static build of getStaticProps is expressed in App Router as cache: 'force-cache' on fetch, or export const revalidate = N. ISR (Incremental Static Regeneration) is a variant on the same option.

_app.tsx / _document.tsxapp/layout.tsx #

The global layout gets simpler.

old — _app.tsx
function MyApp({ Component, pageProps }) {
  return (
    <Provider store={store}>
      <ThemeProvider>
        <Component {...pageProps} />
      </ThemeProvider>
    </Provider>
  );
}
modern — app/layout.tsx
export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const theme = (await cookies()).get('theme')?.value ?? 'light';
  return (
    <html lang="en" data-theme={theme}>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

The magic of _app.tsx taking Component and pageProps to wrap them is gone. It becomes just a React component. Since it is a server component, it knows theme / session from the first response and renders accordingly.

The <Html> / <Head> / <Main> / <NextScript> of _document.tsx also disappear. <html> / <body> are written plainly inside the root layout. Metadata inside <head> moves to a metadata export or a generateMetadata function.

next/routernext/navigation #

old
import { useRouter } from 'next/router';

function Foo() {
  const router = useRouter();
  router.push('/login');
  const { id } = router.query;
}
modern
import { useRouter, useParams, useSearchParams } from 'next/navigation';

function Foo() {
  const router = useRouter();
  router.push('/login');
  const params = useParams();
  const searchParams = useSearchParams();
}

The most common trap is router.query is gone, split into two hooks (useParams / useSearchParams). Old code received dynamic route parameters and query strings mixed into one object, but App Router separates the two explicitly.

Gradual coexistence — /app and /pages #

Next.js officially supports coexistence of the two routers. If no path collides, pages/old-page.tsx and app/new-page/page.tsx can live inside one build.

Two traps to watch for:

  1. Client-side navigation incompatibility — clicking a next/link inside /pages cannot SPA-navigate to an /app route. A full page reload happens. During the migration, you accept that.
  2. API Route behavior differences/pages/api/foo.ts and /app/api/foo/route.ts are different models. When moving them, it is safer to move a whole domain unit (e.g., everything under /api/auth/*) rather than a single API at a time.

The usual order is leaf pages first, then layouts, and read-only first, then mutating routes — two strands moving together.

Redux-only → RSC + Server Actions + a small client store #

In old codebases, Redux usage typically looked like putting all state into a single store. User info, server data, and UI state all mixed inside one store.

The modern starting point is this question.

Is this state server state, or client state — and if client state, is it really global? That is the first question to ask.

Once you split on those three lines, the use case for Redux shrinks fast.

Server state → RSC + Server Actions #

More than half of old code is usually server state. Todo list, user profile, comments, posts. That is not data to keep in a store — it is data the server can refetch.

old — Redux
// store/todos.ts
const todosSlice = createSlice({
  name: 'todos',
  initialState: { items: [], loading: false },
  reducers: {
    setItems: (state, action) => { state.items = action.payload; },
  },
});

// somewhere in a component
useEffect(() => {
  dispatch(fetchTodos());
}, []);
const todos = useSelector((s) => s.todos.items);
modern — RSC
// app/todos/page.tsx
export default async function TodosPage() {
  const items = await db.todos.findAll();
  return <TodoList items={items} />;
}

Store, slice, action, selector, useEffect — all gone. Chapter 24’s “the server is the source of truth” model shrinks the code by 8 ~ 10x.

Real client state goes into smaller tools #

The following are roughly the real client state.

  • Dark mode (though in this book, Cookie keeps it SSR-friendly)
  • Modal / dropdown open-closed
  • Current step of a multi-step form
  • Sidebar collapsed / expanded

That kind of state lives in two places.

  1. Close to the component (useState) — the most common starting point. If it is only used inside one screen, keep it here.
  2. A single Context (Chapter 12) — when several components need to share it, a small Context.

Smaller store tools like Zustand / Jotai fit when Context is not enough (especially when frequent updates make re-renders expensive). Redux Toolkit’s weight is almost always overkill.

Edge cases where Redux still makes sense #

Redux is still the most natural choice in these cases.

  • Complex undo / redo is required (shape editors, code editors)
  • Time-travel debugging carries enough value for the complexity
  • Dozens of async actions need to be expressed as an explicit state machine (when saga is needed)

If none of those three apply, 95% of Redux disappears naturally as you move to the modern stack. The remaining 5% is where Redux really is needed.

Migration procedure #

The usual order for a large codebase.

Redux → modern order
1. Identify server state (Todo / User / Comment ...) and move to RSC + Server Actions
2. Identify shallow client state and move to useState close to the component
3. Split only the truly global client state into a Context (or Zustand)
4. Check the size of what is left in the Redux store — it should be small
5. If the now-small store is unnecessary, remove it; if it is necessary, keep it as is

Do not move everything at once. Cut one domain at a time (e.g., “only the todos slice into RSC”).

fetch-on-mount → RSC data fetching #

old
function Profile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then((r) => r.json())
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <Error />;
  return <article>{user.name}</article>;
}
modern — RSC
import { Suspense } from 'react';

export default function ProfilePage({ params }: { params: Promise<{ id: string }> }) {
  return (
    <Suspense fallback={<Spinner />}>
      <Profile params={params} />
    </Suspense>
  );
}

async function Profile({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const user = await db.users.findById(id);
  return <article>{user.name}</article>;
}

The three useStates, the useEffect, the fetch, the JSON parse, and the error / loading states all collapse into one line of await db.users.findById(id). Error delegates to app/error.tsx, and loading delegates to <Suspense fallback> or app/loading.tsx.

Cases where client fetch is still needed #

Not 100% moves to the server. Client fetch is natural in these cases.

  • Real-time — data changing via SSE / WebSocket.
  • Immediate refresh after a mutation — though this book resolves most of that with Server Action + revalidatePath, so try that first.
  • Progressive load on user action (scroll / search) — the territory of tools like TanStack Query.

The main text of this book did not cover TanStack Query. RSC + Server Actions covers more than 90% of cases. The remaining 10% where client fetch is really required is where TanStack Query’s value shows up.

PropTypes → TypeScript #

PropTypes in old code is runtime validation, and TypeScript is compile-time validation. The roles are different, but TypeScript catches almost everything PropTypes catches, earlier.

old
import PropTypes from 'prop-types';

function Avatar({ src, size, alt }) {
  return <img src={src} width={size} height={size} alt={alt} />;
}
Avatar.propTypes = {
  src: PropTypes.string.isRequired,
  size: PropTypes.number,
  alt: PropTypes.string,
};
Avatar.defaultProps = { size: 40 };
modern
type AvatarProps = {
  src: string;
  size?: number;
  alt?: string;
};

export function Avatar({ src, size = 40, alt = '' }: AvatarProps) {
  return <img src={src} width={size} height={size} alt={alt} />;
}

Default values move into the function parameter destructuring. defaultProps is deprecated for function components from React 19 and is no longer recommended (the change covered in Chapter 28).

Limits of codemod #

The PropTypes → TypeScript conversion of react-codemod gives you a starting point. But you will need to step in by hand in these cases.

  • PropTypes.oneOfType unions translate to TS unions, but the intent is not always converted clearly.
  • Cases where PropTypes did not match reality — sometimes the code declares string in PropTypes but null was flowing in practice. The auto-conversion leaves TS underlining it in red, and walking those red lines once is the real value of the conversion.

The PropTypes conversion is usually the last step of TypeScript adoption. Write new code in TS first, and let existing PropTypes live alongside it for a while — that is the safer order.

CSS-in-JS — styled-components / emotion #

The main text of this book is not opinionated on CSS tools. Just one note: styled-components and emotion have friction with RSC compatibility.

Both libraries depend on runtime style generation, which makes them hard to use directly inside a server component. To use them on top of Next.js App Router, you have to lock them inside a 'use client' boundary, which itself reduces the value of RSC.

The usual modern starting point is CSS Modules or Tailwind CSS. Both decide class names at build time, so they behave the same way on the server and the client.

The two usual migration paths.

  1. Gradual — components where styled-components live stay as client components, while new code or RSC components use CSS Modules / Tailwind.
  2. All at once — auto-conversion tools (like twin.macro) or by hand. When the number of components is not too large.

The choice depends on codebase size and priorities. That is also why the main text of this book stays unopinionated.

Common traps #

A short list of the traps you hit most often during the migration.

Contagion of 'use client' #

Once you make one file 'use client', every component you import inside it gets dragged into the client. To keep the value of server components (direct DB queries, secrets staying safe) intact, keep the client boundary small. The model from Chapter 24.

The temptation of fetch inside useEffect #

Right after pulling in old code, it is tempting to leave useEffect + fetch as is. It works. But it does not get the value of RSC. When you cannot move everything at once, leaves first is usually the safer order.

Context as a Redux leftover #

When tearing out Redux, the part you move into Context can swell back up into a “global store.” Context is best split into small units, as Chapter 12 covers. A single huge AppContext becomes a smaller copy of Redux.

Pages Router’s _app.tsx Provider flickering on both sides #

During /app and /pages coexistence, things like ThemeProvider / Redux Provider run independently in each router. If you see a flicker or a state reset when a route changes, that is usually the cause. Inside one domain, unifying on a single router is the safest.

Buildup of TypeScript any #

As you move old code, temporary as any casts have a way of becoming permanent. We recommend the habit of a TODO comment + a GitHub issue alongside every as any. At the end of the migration, sweep them with one grep.

Migration procedure for large codebases #

Moving a codebase of hundreds of components / dozens of routes into this book’s style does not finish in days. It becomes a quarterly or half-year plan. The following order causes the fewest accidents.

6-stage migration
1. TypeScript adoption          — safety net. First, and slowest.
2. Class → function + hooks      — one component at a time. The change with the lowest regression risk.
3. Partial App Router adoption   — start with isolated areas like /(marketing).
4. Gradual RSC transition       — every new route is RSC, old routes stay.
5. Reduce Redux dependency      — start with server state moving to RSC.
6. Final — remove Pages Router    — last. After every route has moved to /app.

Detailed recommendations for each stage.

Stage 1 — TypeScript adoption #

First, and slowest. Do not jump straight to strict. Start with allowJs: true + strict: false and grow .ts / .tsx files gradually. The starting point covered in Chapter 16 (TypeScript setup).

first tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "ES2022"],
    "allowJs": true,
    "jsx": "preserve",
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": false,
    "noUncheckedIndexedAccess": false,
    "skipLibCheck": true
  }
}

The gradual transition to strict: true is done by flipping one compiler flag at a time. noImplicitAny first, then strictNullChecks, then the rest — that is the usual order.

Stage 2 — Class → function + hooks #

One component at a time, so this is the safest. Apply the mappings from the “Class component → function + hooks” section above as is. One or two components per PR, gradual conversion. If your regression tests are working, you can consider auto-conversion tools, but doing it by hand is usually how the small cleanups get picked up along the way.

Stage 3 — Partial App Router adoption #

Do not move every page. Pick one isolated area (e.g., marketing / login / a new feature) and build it under /app. Confirm stability in the two-router coexistence mode.

Stage 4 — Gradual RSC transition #

Every new page is RSC. Old pages stay. Once the value of RSC (server-side data fetching, secrets safe, faster first paint) shows up on one page, the motivation to migrate the others appears naturally.

Stage 5 — Reduce Redux dependency #

Follow the procedure in the “Redux-only” section as is. One domain at a time, server state first.

Stage 6 — Remove Pages Router #

Last. Once the routes left in /pages reach zero, delete the folder and clean up the compatibility flags you temporarily enabled in next.config.ts.

Keeping the site alive through the migration #

To keep the site alive while you transform it inside each stage, a few habits help.

  • Small PR units — one component / one route at a time. Revert should be easy.
  • Feature-flag the exposure — expose new routes to a slice of users first (PostHog flags, env vars). The pattern from Chapter 33.
  • Check data compatibility across both models — if both Redux and RSC look at the same backend API, check the consistency of the data path with every PR.
  • Keep one E2E scenario — confirm that the Playwright scenario from Chapter 30 runs the same way before and after migration. The earliest safety net for catching user-flow regressions.

Hands-on #

Pick a piece of old React code you have on hand (if any) or a small sample project, and try these once each.

  1. Pick one Class component and convert to function + hooks. Picking one with componentDidMount / componentDidUpdate / componentWillUnmount lets you taste the conversion fully. Re-check useEffect from Chapter 11.
  2. Pick one page and move Pages Router → App Router. Pick a page with getServerSideProps and move it to RSC. Confirm that the props serialization is gone and the two functions collapse to one.
  3. Pick one Redux slice and move to RSC + Server Action. Pick a slice that is server state, empty the store, and move it to direct fetch inside RSC + Server Action. Try counting the line counts of the old code and the modern code.
  4. PropTypes one file → TypeScript. After the codemod auto-conversion, hand-review only the parts with red squiggles. The “cases where PropTypes did not match reality” tend to show up there.

After these four, the picture of moving your whole codebase forms in your head.

Exercises #

  1. Lifecycle mapping. Move the following old code into the hook model, line by line. (a) componentDidMount doing fetch + state set, (b) componentDidUpdate comparing props then re-fetching, (c) componentWillUnmount cancelling a subscription. Answer how the resulting useEffect dependency array and cleanup function come out.
  2. Server state vs client state. Classify each of the five items as server state or client state, and if client state whether it is truly global. (a) the logged-in user’s info, (b) dark mode, (c) the ID of the currently open modal, (d) cart items, (e) screen width (for responsive branches). Write one line on which tool in this book takes responsibility for each.
  3. Pick a migration order. Given a hypothetical codebase, in what order would you move the following items? (a) 200 Class components, (b) 50 Pages Router routes, (c) 30 Redux store slices, (d) 100 PropTypes files, (e) 800 styled-components components. Use the 6-stage procedure of this appendix as a reference, and write down your own priorities (service stability vs speed of modern transition) as well.

In one line: old React code maps 1:1 to this book’s modern style through line-by-line conversions (Class → function + hooks, Pages → App, Redux server state → RSC + Server Action, fetch-on-mount → RSC fetch, PropTypes → TS), and a large codebase moves through the 6-stage order of TypeScript adoption → function conversion → partial App Router adoption → gradual RSC → Redux reduction → Pages Router removal with the fewest accidents. Small PR units, feature-flagged exposure, and one E2E scenario for regression are the safety net that keeps the site alive through the move.

Closing the book #

This appendix closes both the main text and the appendix of React.

Let us restate what this book promised. To teach the React standard at the 2026 point in time (function + hooks, App Router, RSC + Server Actions, TypeScript first) in a single style from page one, and to connect basics through fullstack in one unbroken curve inside a single volume. Anyone who finished Chapters 1 ~ 34 plus this appendix stands at the point where both of those promises arrive at their goal.

Areas this book did not cover (React Native, Remix, TanStack Start, design systems, animation, WebGL) are covered in separate books. If this book became a starting point on the way to those areas, this book’s role has been served well. Happy coding.

X