React NotesRohit’s interview study guide
Chapter 18

Patterns & Architecture

Reusing logic with higher-order components, render props and hooks; compound and container/presentational components; structuring projects from small apps to enterprise monorepos; building a component library with Storybook; and micro-frontends with Module Federation.

10 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Higher-order components

A higher-order component (HOC) is a function that takes a component and returns a new component that renders the original one with something added: extra props, a check before rendering, data, a wrapper. The name mirrors higher-order functions — functions that take or return functions — and the idea is the same: withX(Component) returns an enhanced Component.

A good mental model is gift wrapping. The present (your component) doesn't change; the HOC wraps it in a layer that can decide whether to hand it over at all (an auth check), what to hand over with it (extra props), or what to show while you wait (a loading state). The person receiving it (the parent) only sees the wrapped box.

HOCs were the way to share behaviour between components before hooks (2015–2019): connect() from react-redux, withRouter from React Router 4/5, withStyles from Material-UI v4. You'll still meet them in older codebases and in interviews, and a few legitimately remain (React.memo is a HOC, and so is withErrorBoundary in react-error-boundary). For new shared logic, hooks have replaced them — see HOCs vs render props vs hooks.

From my notes — the extraProp example, step by step:

withExtraProp.jsx
// Step 1: a normal component. It expects extraProp, but doesn't know where it comes from.
export const MessageComponent = ({ message, extraProp }) => {
  return (
    <div>
      <h2>{message}</h2>
      <p>{extraProp}</p>
    </div>
  );
};

// Step 2: the HOC. Takes a component, returns a new one that adds a prop.
export const withExtraProp = (WrappedComponent) => {
  return (props) => {
    const extraProp = "This is an extra prop from HOC!";
    return <WrappedComponent {...props} extraProp={extraProp} />;
  };
};

// Step 3: enhance once, at module level.
export const EnhancedMessageComponent = withExtraProp(MessageComponent);

// Step 4: render it. The caller passes only message.
export const App = () => {
  return (
    <div>
      <EnhancedMessageComponent message="Hello from HOC!" />
    </div>
  );
};
What’s happening
  1. When the module loads, withExtraProp(MessageComponent) runs once. It doesn't render anything; it just returns a new function component, (props) => …, which closes over WrappedComponent (here MessageComponent). That new component is EnhancedMessageComponent.
  2. React renders App, which renders <EnhancedMessageComponent message="Hello from HOC!" />. React calls the inner arrow function with props = { message: "Hello from HOC!" }.
  3. The wrapper computes extraProp and returns <MessageComponent {...props} extraProp="This is an extra prop from HOC!" />. The spread forwards everything the caller passed; extraProp is added on top (and would override a caller's extraProp, because it comes later).
  4. MessageComponent renders an <h2> with "Hello from HOC!" and a <p> with the injected string. The test checks both texts.
  5. The caller never mentioned extraProp. That's the HOC idea: behaviour attached from outside, with the wrapped component unaware of where its props came from.

The authentication HOC

From my notes: "HOC great example" — check whether the user is signed in before rendering something. My notes hard-coded isAuthenticated = true with a comment; here it reads a real context so the example actually runs (both branches are tested):

withAuth.jsx
import { createContext, useContext } from "react";

export const AuthContext = createContext({ user: null });

export const withAuth = (WrappedComponent) => {
  function WithAuth(props) {
    const { user } = useContext(AuthContext);
    if (!user) {
      return <div>Please log in to access this content.</div>;
    }
    // If authenticated, render the wrapped component with its original props
    return <WrappedComponent {...props} />;
  }
  WithAuth.displayName = `withAuth(${WrappedComponent.displayName || WrappedComponent.name})`;
  return WithAuth;
};

const SecretComponent = () => <h1>Secret Content: You can only see this if you are logged in.</h1>;
export const ProtectedComponent = withAuth(SecretComponent);
What’s happening
  1. withAuth(SecretComponent) runs once at module load and returns WithAuth, a named function component. Its displayName becomes withAuth(SecretComponent), which is what React DevTools and error stacks show instead of "Anonymous".
  2. When <ProtectedComponent /> renders, WithAuth reads the current user from AuthContext — HOCs can call hooks because the returned component is an ordinary function component.
  3. With no provider (or user: null), it returns the "Please log in" message and never renders SecretComponent at all.
  4. Inside <AuthContext value={{ user: { name: "Rohit" } }}>, the check passes and it renders <SecretComponent {...props} /> — the secret heading appears. The test renders both cases.
  5. The same withAuth can wrap any number of pages. That reuse was the selling point in my notes: "This HOC can be reused across multiple components that require authentication."

A data-fetching HOC

My notes listed "Fetching data HOC" as a real-world example but had no code for it. Here's one. It's configured first, then applied (withData(url)(Component)), the same two-step shape as react-redux's connect(mapState)(Component):

withData.jsx
import { useEffect, useState } from "react";

export const withData = (url, propName = "data") => (WrappedComponent) => {
  function WithData(props) {
    const [state, setState] = useState({ loading: true, data: null, error: null });

    useEffect(() => {
      let cancelled = false;
      fetch(url)
        .then((res) => {
          if (!res.ok) throw new Error(`HTTP ${res.status}`);
          return res.json();
        })
        .then((data) => !cancelled && setState({ loading: false, data, error: null }))
        .catch((error) => !cancelled && setState({ loading: false, data: null, error }));
      return () => {
        cancelled = true;
      };
    }, []);

    if (state.loading) return <p>Loading…</p>;
    if (state.error) return <p role="alert">Failed: {state.error.message}</p>;
    return <WrappedComponent {...props} {...{ [propName]: state.data }} />;
  }
  WithData.displayName = `withData(${WrappedComponent.displayName || WrappedComponent.name})`;
  return WithData;
};

const UserNames = ({ users, title }) => (
  <section>
    <h2>{title}</h2>
    <ul>
      {users.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  </section>
);

export const UsersWithData = withData("/api/users", "users")(UserNames);
What’s happening
  1. withData("/api/users", "users") returns a function that takes a component; calling that with UserNames returns WithData. The URL and prop name are captured in closures, so each enhanced component remembers its own configuration.
  2. On first render state.loading is true, so it shows "Loading…". After the commit, the effect starts the fetch.
  3. When the JSON arrives, setState({ loading: false, data, … }) re-renders. Now it renders <UserNames title="Team" users={[…]} /> — the computed key { [propName]: state.data } is how the data lands in a prop called users.
  4. If the response isn't OK, the thrown Error("HTTP 500") lands in catch and the component shows "Failed: HTTP 500". The cancelled flag stops a late response from setting state after unmount.
  5. The test stubs fetch for both outcomes. Compare this with useFetch: a data-fetching hook: the same logic, but a hook returns the data to the component instead of wrapping it.
AdvancedHOC housekeeping: names, statics, refs and prop collisions

The classic list of things a well-behaved HOC has to handle, and how React 19 changes it:

  • displayName: set it as above, or every wrapped component shows up as Anonymous / props in DevTools.
  • Static methods aren't copied: if Page.loadData exists, withAuth(Page).loadData is undefined. Libraries used hoist-non-react-statics to copy them; with hooks this rarely matters any more.
  • Refs: in React 18 and earlier, ref wasn't a prop, so <Enhanced ref={r} /> pointed at the wrapper (or warned for function components) unless the HOC used forwardRef. In React 19, ref is a normal prop and {...props} forwards it automatically.
  • Prop collisions: two HOCs that both inject data silently overwrite each other — the later spread wins. Configurable prop names (like propName above) are the usual defence.
  • Wrapper hell: withRouter(connect(map)(withStyles(styles)(withAuth(Page)))) produced four extra layers in the component tree, and it was hard to see which layer provided which prop. This is the main reason hooks won.

#Render props

A render prop is a prop whose value is a function that returns JSX. The component that receives it does the work — fetching, tracking the mouse, managing open/closed state — and then calls the function to find out what to render, passing in the results. My notes put it well: it "does the core logic but doesn't decide how it would be displayed".

Mental model: the component is a chef who cooks but doesn't plate. It hands you the ingredients (data, state, callbacks) and you decide how they appear. HOCs push props into a component from outside; render props hand values out to whoever is rendering.

From my notes — DataFetcher, with two different renderings of the same data:

DataFetcher.jsx
export const DataFetcher = ({ render }) => {
  const data = "Some fetched data"; // simulating fetched data
  return render(data);
};

export const App = () => {
  return (
    <div>
      <DataFetcher render={(data) => <h1>{data}</h1>} />
      <DataFetcher render={(data) => <p>{data}</p>} />
    </div>
  );
};
What’s happening
  1. App renders two DataFetchers, each with a different function in the render prop. The functions are just values here — nothing has been rendered yet.
  2. React renders the first DataFetcher. It produces its data ("Some fetched data") and, instead of returning its own JSX, returns render(data) — it calls the parent's function and returns whatever that gives back, an <h1>.
  3. The second DataFetcher runs the same logic and calls its own render, which returns a <p>.
  4. The result: one heading and one paragraph with the same text. The logic exists once; the presentation is decided by each caller. The test checks the h1 and the p.

The more common modern spelling uses children as the function. Here the component has real state, which is what makes render props useful:

MouseTracker.jsx
import { useState } from "react";

export function MouseTracker({ children }) {
  const [pos, setPos] = useState({ x: 0, y: 0 });
  return (
    <div data-testid="area" onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
      {children(pos)}
    </div>
  );
}

export const Coordinates = () => (
  <MouseTracker>{({ x, y }) => <p>The mouse is at {x}, {y}</p>}</MouseTracker>
);
What’s happening
  1. MouseTracker owns the state pos, starting at { x: 0, y: 0 }, and listens for mousemove on its <div>.
  2. Its children isn't JSX — it's a function. On every render, MouseTracker calls children(pos) and puts the result inside the <div>.
  3. The first render shows "The mouse is at 0, 0". When the mouse moves to (40, 25), the handler calls setPos({ x: 40, y: 25 }), MouseTracker re-renders, calls children again with the new position, and the text becomes "The mouse is at 40, 25".
  4. Coordinates decides what to show; MouseTracker decides when and with what data. A different caller could draw a cat image at those coordinates using the same tracker — the original example in the React docs.
  5. The test fires a mouseMove with clientX: 40, clientY: 25 and checks the text.

From my notes — when to use render props:

  1. Dynamic content rendering: the layout changes based on the component's data or state.
  2. Encapsulating logic: animation, data fetching or event handling (like mouse movement) lives in one component; the consumer decides the rendering.
  3. Multiple consumers with shared logic: several places need the same behaviour but render different things.

Where you still see render props in 2026: fallbackRender in react-error-boundary, row renderers in virtualised lists and table libraries, headless UI libraries (Downshift, React Aria's render-prop children), Formik's <Field>, and React Router 5's <Route render>.

#HOCs vs render props vs hooks

HOCs, render props and custom hooks all answer the same question: how do two components share stateful logic without sharing UI? The difference is where the logic sits and how its values reach your component:

  • A HOC wraps your component and pushes values in as props.
  • A render prop component wraps your JSX and hands values out as function arguments.
  • A custom hook runs inside your component and returns values as variables.

Here is one piece of logic — the current window width — written all three ways. The hook is the source of truth; the other two are built on it in a few lines, which is itself a good illustration of why hooks won:

windowWidth.jsx
import { useEffect, useState } from "react";

// 1. Custom hook (React 16.8+): the logic itself
export function useWindowWidth() {
  const [width, setWidth] = useState(() => window.innerWidth);
  useEffect(() => {
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  return width;
}

// 2. HOC: pushes the value in as a prop
export const withWindowWidth = (Component) => {
  function WithWindowWidth(props) {
    const width = useWindowWidth();
    return <Component {...props} width={width} />;
  }
  return WithWindowWidth;
};

// 3. Render prop: hands the value out to a function
export function WindowWidth({ children }) {
  return children(useWindowWidth());
}

// Three consumers that render the same thing
export function HookLabel() {
  const width = useWindowWidth();
  return <p>hook: {width}</p>;
}
export const HocLabel = withWindowWidth(({ width }) => <p>hoc: {width}</p>);
export const RenderPropLabel = () => <WindowWidth>{(width) => <p>render prop: {width}</p>}</WindowWidth>;
What’s happening
  1. useWindowWidth reads window.innerWidth once (lazy initial state), subscribes to resize in an effect, and returns the number. Each component that calls it gets its own state and its own listener.
  2. HookLabel calls the hook directly: width is a local variable. No wrapper appears in the component tree, and the name width is chosen by the consumer.
  3. HocLabel is withWindowWidth(…): the wrapper calls the hook and passes width as a prop. The consumer must accept a prop named exactly width, and the tree gains a WithWindowWidth layer.
  4. RenderPropLabel renders <WindowWidth> with a function child, which receives width as an argument — no naming clash, but an extra level of nesting in the JSX.
  5. In the test, jsdom starts at a width of 1024, so all three show 1024. Setting window.innerWidth = 500 and dispatching resize updates all three to 500 — identical behaviour, three shapes.
HOCRender propCustom hook
How values arriveprops injected from outsidearguments to a function you passreturn value inside your component
Extra layers in the treeone wrapper per HOCone wrapper per componentnone
Naming clashesyes — two HOCs can inject the same propno — you name the argumentsno — you name the variables
Combining severalwithA(withB(withC(X))), hard to tracenested callbacks ("pyramid")one line per hook
TypeScripttricky (see Typing a higher-order component)easyeasy
Can wrap rendering itselfyes (guards, boundaries, layouts)yesno — a hook can't render around you
Can a custom hook replace an error boundary HOC?
React
const SafeWidget = withErrorBoundary(Widget, { fallback: <p>Oops</p> });
// Could this be written as const safe = useErrorBoundary(); inside Widget?
Show answer

No. A hook can't catch errors thrown while its own component (or children) render.

What’s happening
  1. An error boundary works by being an ancestor of the component that throws: React unwinds to the nearest class component with getDerivedStateFromError and renders its fallback instead.
  2. A hook runs inside Widget's render. If Widget's render throws, the hook's component is the one failing — there's nothing above it to render the fallback.
  3. That's why withErrorBoundary is a HOC: it adds an <ErrorBoundary> around Widget. react-error-boundary's useErrorBoundary hook exists, but it only lets a component send an error (from an event handler or async code) up to an existing boundary.
  4. The general rule: logic that produces values fits a hook; behaviour that must wrap rendering needs a component (HOC or wrapper).

#Compound components

Added

Compound components are a set of components that only make sense together and share hidden state: <Tabs>, <Tabs.List>, <Tabs.Tab>, <Tabs.Panel>. The parent owns the state (which tab is selected); the children read and update it through context, so the user of the API arranges the pieces however they like without wiring props between them.

The mental model is HTML's own <select> and <option>: you never tell an <option> whether it's selected or pass it an onChange; it just works because it's inside a <select>. Compound components give your own components that property. They're the backbone of headless UI libraries such as Radix UI, React Aria, Headless UI and Ark UI.

Tabs.jsx
import { createContext, useContext, useId, useState } from "react";

const TabsContext = createContext(null);

function useTabs() {
  const ctx = useContext(TabsContext);
  if (!ctx) throw new Error("Tabs.* components must be used inside <Tabs>");
  return ctx;
}

export function Tabs({ defaultValue, children }) {
  const [value, setValue] = useState(defaultValue);
  const baseId = useId();
  return <TabsContext value={{ value, setValue, baseId }}>{children}</TabsContext>;
}

function List({ children }) {
  return <div role="tablist">{children}</div>;
}

function Tab({ value, children }) {
  const { value: selected, setValue, baseId } = useTabs();
  const isSelected = selected === value;
  return (
    <button
      role="tab"
      id={`${baseId}-tab-${value}`}
      aria-selected={isSelected}
      aria-controls={`${baseId}-panel-${value}`}
      onClick={() => setValue(value)}
    >
      {children}
    </button>
  );
}

function Panel({ value, children }) {
  const { value: selected, baseId } = useTabs();
  if (selected !== value) return null;
  return (
    <div role="tabpanel" id={`${baseId}-panel-${value}`} aria-labelledby={`${baseId}-tab-${value}`}>
      {children}
    </div>
  );
}

Tabs.List = List;
Tabs.Tab = Tab;
Tabs.Panel = Panel;

export function Settings() {
  return (
    <Tabs defaultValue="profile">
      <Tabs.List>
        <Tabs.Tab value="profile">Profile</Tabs.Tab>
        <Tabs.Tab value="billing">Billing</Tabs.Tab>
      </Tabs.List>
      <Tabs.Panel value="profile">Your name and photo</Tabs.Panel>
      <Tabs.Panel value="billing">Cards and invoices</Tabs.Panel>
    </Tabs>
  );
}
What’s happening
  1. <Tabs defaultValue="profile"> owns the state: value starts as "profile". It puts { value, setValue, baseId } into TabsContext (React 19 lets you render <TabsContext value={…}> directly as the provider).
  2. Each Tab and Panel calls useTabs() to read that shared state. Nobody passes selected or onSelect props to them — the context connects the pieces wherever they sit in the tree.
  3. On first render, the Profile tab has aria-selected="true", the Billing tab "false", and only the Profile panel renders ("Your name and photo"); the Billing panel returns null.
  4. Clicking "Billing" calls setValue("billing"). Tabs re-renders with a new context value, every consumer re-renders, and now the Billing tab is selected and "Cards and invoices" is shown.
  5. useId() gives each Tabs instance a unique baseId, so aria-controls / aria-labelledby link the right tab and panel even with several tab sets on a page. useTabs() throws a clear message if someone renders <Tabs.Tab> outside <Tabs>.
  6. Tabs.List = List attaches the parts as properties, giving the dotted API. Exporting TabsList, Tab, TabPanel separately works just as well and tree-shakes slightly better.
AdvancedThe older way: Children.map and cloneElement

Before context was ergonomic (pre-2018), compound components were built by having the parent loop over its children and inject props with React.Children.map(children, (child) => cloneElement(child, { isSelected: … })). It works only when the parts are direct children — wrap a Tab in a <div> or your own component and it silently stops receiving props. The React docs now list Children and cloneElement as legacy APIs. Context-based compound components don't care how deeply the parts are nested, which is why every modern library uses them.

#Container and presentational components

This pattern splits a feature into two kinds of component. A container knows how things work: it fetches data, holds state, talks to the store, and passes plain values and callbacks down. A presentational component knows how things look: it receives everything through props, holds at most UI state (is this dropdown open?), and has no idea where the data came from.

From my notes, under component design principles: separation of concerns ("each component should do one thing well; avoid mixing logic for different concerns like fetching data and UI rendering within the same component"), props-driven design (components customised through props, not internal changes) and keeping components as stateless as possible, lifting state to a parent when needed. Container/presentational is the classic way of applying those.

The mental model is a TV and its remote. The screen (presentational) just shows whatever it's given; the remote (container) decides what to show. You can test or redesign the screen without touching the remote.

UserList.jsx
import { useEffect, useState } from "react";

// Presentational: props in, markup out. Easy to test, easy to put in Storybook.
export function UserListView({ users, loading, error, onRefresh }) {
  if (loading) return <p>Loading…</p>;
  if (error) return <p role="alert">{error}</p>;
  return (
    <div>
      <button onClick={onRefresh}>Refresh</button>
      <ul>
        {users.map((u) => (
          <li key={u.id}>{u.name}</li>
        ))}
      </ul>
    </div>
  );
}

// Container: owns data and behaviour, renders the view.
export function UserListContainer({ fetchUsers }) {
  const [state, setState] = useState({ users: [], loading: true, error: null });
  const [version, setVersion] = useState(0);

  useEffect(() => {
    let cancelled = false;
    fetchUsers()
      .then((users) => !cancelled && setState({ users, loading: false, error: null }))
      .catch((e) => !cancelled && setState({ users: [], loading: false, error: e.message }));
    return () => {
      cancelled = true;
    };
  }, [fetchUsers, version]);

  return <UserListView {...state} onRefresh={() => setVersion((v) => v + 1)} />;
}
What’s happening
  1. UserListView is a pure function of its props: given loading, it shows "Loading…"; given error, an alert; otherwise a Refresh button and the list. It never fetches and never knows about fetchUsers.
  2. UserListContainer owns the state { users, loading, error } and a version counter. Its effect calls fetchUsers() and stores the result — it's the only place that knows how data is obtained.
  3. It renders <UserListView {...state} onRefresh={…} />. Clicking Refresh bumps version, which is in the effect's dependency array, so the effect runs again and refetches. (On a refetch the old list stays visible because loading isn't reset — a deliberate choice here.)
  4. The view's test renders it with hand-made props — users=[…], error="Boom" — no mocks needed. The container's test passes a fake fetchUsers and checks that the names appear and that Refresh calls it a second time.
  5. Because fetchUsers is a prop, the container is also easy to reuse with a different data source; that's dependency injection in its simplest form.

Composition over inheritance

My notes asked "What's the meaning of composition over inheritance??" and answered it: a component is "flexible and generic — doesn't care what is being passed to it, either through props or children". React has no component inheritance in practice; you build bigger components by putting smaller ones inside each other and by passing JSX into slots. The Modal from my notes (typed, as it was written):

Modal.tsx
import type { FC, ReactNode } from "react";

type ModalProps = {
  header: ReactNode;
  body: ReactNode;
  footer: ReactNode;
};

export const Modal: FC<ModalProps> = ({ header, body, footer }) => {
  return (
    <div className="modal">
      <div className="modal-header">{header}</div>
      <div className="modal-body">{body}</div>
      <div className="modal-footer">{footer}</div>
    </div>
  );
};

export const App = () => (
  <Modal header={<h2>Modal Title</h2>} body={<p>This is the modal content</p>} footer={<button>Close</button>} />
);
What’s happening
  1. Modal takes three props typed as ReactNode — slots. It decides the layout (header, body, footer, in that order, with their classes) but not the content.
  2. App passes three different pieces of JSX. They're created by App and handed over as values; Modal places each one in its slot.
  3. A different screen could pass a form as body and two buttons as footer without any change to Modal. That's the "doesn't care what is being passed" flexibility from my notes.
  4. Inheritance would mean a ConfirmModal extends Modal that overrides methods — fragile and unnecessary. In React, specialisation is composition too: function ConfirmModal(props) { return <Modal header={…} body={props.message} footer={…} />; }.

My notes also composed a form from smaller reusable parts — <form><Input label="Username" /><Input label="Password" type="password" /><Button label="Submit" onClick={handleSubmit} /></form> — "smaller components like Input and Button are reusable and can be composed to create complex UIs without re-writing logic." More on slots and children in children and composition over inheritance.

#Structuring a React project

React doesn't prescribe a folder structure, which is why "how would you structure a large React app?" is a favourite interview question. The answer that scales is group by feature, not by file type: everything for "checkout" — its components, hooks, API calls, state and tests — lives in one folder, and features talk to each other only through a small public API.

The mental model is a city of neighbourhoods. A small app can be one street sorted by kind (all components here, all hooks there). A large one needs neighbourhoods (features) with their own shops, a shared utility district (UI kit, helpers), and a city hall (the app shell: routing, providers) — and rules about who may build roads into whose neighbourhood.

From my notes: "How to design react projects (I mean their structure)", "Better diagram of react project structure — I LIKE THIS", "Tests can be like this", "Context folder can be broken down something like…" and "ENTERPRISE level apps structure". All five were diagrams (screenshots) that didn't survive into the text version of the doc, so I can't reproduce what they showed. The structures below are the standard ones those headings describe; compare them with the original diagrams if you have them.

Small app: group by type

Output
src/
  components/      Button.tsx, Modal.tsx, Header.tsx
  pages/           HomePage.tsx, SettingsPage.tsx
  hooks/           useDebounce.ts, useLocalStorage.ts
  context/         AuthContext.tsx, ThemeContext.tsx
  services/        api.ts
  utils/           formatDate.ts
  App.tsx
  main.tsx
What’s happening
  1. Files are grouped by what they are: components, pages (route-level components), hooks, contexts, API services and pure helpers.
  2. main.tsx creates the root and renders App; App.tsx holds routing and top-level providers.
  3. This is perfectly fine up to a few dozen components: everything is one hop away and there are no rules to learn.
  4. It breaks down when the app grows: a change to "checkout" touches components/, hooks/, services/ and context/, and nothing tells you which files belong to which feature or which ones are safe to delete.

Medium to large app: group by feature

Output
src/
  app/                    # the shell: wiring only
    App.tsx
    router.tsx
    providers.tsx         # QueryClientProvider, store, theme, auth
  features/
    auth/
      components/         LoginForm.tsx, LoginForm.test.tsx
      hooks/              useAuth.ts
      context/            AuthContext.ts, AuthProvider.tsx
      api/                login.ts
      index.ts            # public API: export { LoginForm, useAuth, AuthProvider }
    checkout/
      components/         CartSummary.tsx, PaymentForm.tsx
      hooks/              useCart.ts
      store/              cartSlice.ts
      api/                orders.ts
      index.ts
  shared/                 # used by many features, knows about none
    ui/                   Button.tsx, Modal.tsx, Spinner.tsx
    hooks/                useDebounce.ts
    lib/                  http.ts, formatDate.ts
  pages/                  # thin route components that compose features
    CheckoutPage.tsx
  main.tsx
What’s happening
  1. features/<name>/ holds everything for one feature, including its tests next to the code (LoginForm.test.tsx beside LoginForm.tsx). Deleting a feature means deleting a folder.
  2. Each feature's index.ts is its public API. Other code imports from "@/features/auth", never from features/auth/components/LoginForm — so a feature can reorganise its insides without breaking anyone.
  3. The context for a feature is broken into pieces — the context object, the provider component and the consumer hook — which is one common reading of "the context folder can be broken down". It keeps the provider (state, effects) separate from the tiny hook every consumer imports.
  4. shared/ contains code with no feature knowledge: the design-system components, generic hooks, the HTTP client. The dependency rule is one-way: features may import shared, shared may never import a feature.
  5. app/ is the composition root (router, providers), and pages/ are thin route components that combine features. Routing doesn't live inside features, so features stay reusable across pages.

Enterprise scale: a monorepo of apps and packages

Output
repo/
  apps/
    web/                  # the customer-facing app (feature folders inside, as above)
    admin/                # back-office app
  packages/
    ui/                   # the design system / component library, versioned
    api-client/           # generated from OpenAPI or GraphQL schema
    config-eslint/        # shared lint rules, including import boundaries
    config-ts/            # shared tsconfig bases
  package.json            # workspaces
  turbo.json              # or nx.json: task graph, caching
What’s happening
  1. Several apps live in one repository and share code through internal packages (packages/ui, packages/api-client) linked by npm/pnpm workspaces rather than copy-paste.
  2. A build tool like Turborepo or Nx knows the dependency graph between apps and packages, so build and test only run for what changed and results are cached locally and in CI.
  3. Boundaries are enforced, not just documented: Nx module-boundary rules or eslint-plugin-boundaries fail the lint if, say, shared imports a feature or one app imports another app's internals.
  4. The shared UI package is the component library, with its own Storybook. Teams that need independent deployments go one step further to micro-frontends.
  5. My notes' microfrontend section describes exactly this shape at work: "a combination of separate React web apps all in one mono repo" (more in Micro-frontends).

Path aliases keep imports readable once folders are deep — @/features/auth instead of ../../../features/auth. With Vite, you declare the alias for the bundler and the same path for TypeScript:

vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  resolve: { alias: { "@": "/src" } },
});
// tsconfig.json: "compilerOptions": { "paths": { "@/*": ["./src/*"] } }
What’s happening
  1. resolve.alias tells Vite (and Vitest, which reads the same config) to rewrite @/… imports to /src/… when bundling.
  2. TypeScript resolves imports separately, so paths in tsconfig.json must declare the same mapping, or the editor reports "Cannot find module '@/features/auth'".
  3. Keep the two in sync, or use a plugin such as vite-tsconfig-paths that reads paths and configures Vite from it.

#Building a reusable component library

A component library (my notes call it an SDK of reusable frontend components) is a package of UI building blocks that several apps install and use — buttons, inputs, dialogs, tables — built once, documented once, versioned and published. It's how a company keeps ten products looking and behaving consistently.

The mental model: you are now writing an API, not a screen. Every prop is a public contract other teams depend on; changing it is a breaking change; your users will use the component in ways you didn't imagine. So library components lean hard on the design principles from my notes — props-driven, composable, as stateless as possible — and on boring packaging details that apps never think about.

From my notes, the key points:

  • Component design: separation of concerns, props-driven design, keep components stateless where possible and allow external control ("state lifting").
  • Composition: build complex UI by combining small components ("composition over inheritance").
  • Structure of the library, styling ("choose between CSS Modules or CSS-in-JS (styled-components)"), theming ("if you need theming, styled-components would give you that"), and documentation with Storybook.
  • Packaging an SDK: modular packaging with Webpack or Rollup, tree shaking so unused components aren't bundled, semantic versioning (major.minor.patch), and distribution via npm or a private registry.

A library-grade component

"Allow external control" is the subtle one. A library Toggle must work uncontrolled (it keeps its own state, the app passes defaultPressed) and controlled (the app owns the state via pressed + onPressedChange). A small hook handles both, the same way Radix and React Aria do it:

Toggle.tsx
import { useState, type ComponentProps } from "react";

export function useControllableState<T>(controlled: T | undefined, defaultValue: T, onChange?: (value: T) => void) {
  const [internal, setInternal] = useState(defaultValue);
  const isControlled = controlled !== undefined;
  const value = isControlled ? controlled : internal;
  const setValue = (next: T) => {
    if (!isControlled) setInternal(next);
    onChange?.(next);
  };
  return [value, setValue] as const;
}

type ToggleProps = Omit<ComponentProps<"button">, "onChange"> & {
  pressed?: boolean;
  defaultPressed?: boolean;
  onPressedChange?: (pressed: boolean) => void;
};

export function Toggle({ pressed, defaultPressed = false, onPressedChange, onClick, className, ...rest }: ToggleProps) {
  const [isOn, setIsOn] = useControllableState(pressed, defaultPressed, onPressedChange);
  return (
    <button
      type="button"
      {...rest}
      aria-pressed={isOn}
      className={["ui-toggle", isOn && "ui-toggle--on", className].filter(Boolean).join(" ")}
      onClick={(e) => {
        onClick?.(e); // the app's handler runs first…
        if (!e.defaultPrevented) setIsOn(!isOn); // …and can cancel the toggle
      }}
    />
  );
}
What’s happening
  1. useControllableState decides who owns the value. If the caller passed pressed (not undefined), the component is controlled and uses it; otherwise it uses its own useState, seeded from defaultPressed.
  2. setValue always calls onChange so the app hears about every change, but only updates internal state when uncontrolled — a controlled toggle changes only when the parent passes a new pressed.
  3. ToggleProps extends the native <button> props (minus onChange, which we don't use), so consumers can pass aria-label, disabled, data-*, children and in React 19 a ref, all forwarded via ...rest. The spread comes before aria-pressed, className and onClick, so a consumer can't accidentally replace the toggle's own behaviour; their onClick is composed instead — it runs first and can call e.preventDefault() to cancel the toggle.
  4. Uncontrolled test: <Toggle aria-label="Bold" /> starts with aria-pressed="false", one click makes it "true". Controlled test: with pressed={false} and a spy for onPressedChange, a click calls the spy with true but aria-pressed stays "false" until the parent re-renders with pressed={true}.
  5. The class names (ui-toggle, ui-toggle--on) are stable, prefixed hooks for the library's stylesheet, and className lets apps add their own. aria-pressed makes it a proper toggle button for screen readers — accessibility is part of a library's contract.

Packaging: what makes a library tree-shakable

package.json
{
  "name": "@acme/ui",
  "version": "2.4.0",
  "type": "module",
  "exports": {
    ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
    "./styles.css": "./dist/styles.css"
  },
  "files": ["dist"],
  "sideEffects": ["**/*.css"],
  "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" },
  "devDependencies": { "react": "^19.3.0", "react-dom": "^19.3.0", "typescript": "^7.0.0", "vite": "^8.0.0" },
  "publishConfig": { "registry": "https://npm.acme.internal/" }
}
What’s happening
  1. "type": "module" and an exports map publish ES modules with their .d.ts types. Bundlers can only tree-shake static import/export; a CommonJS build would pull in the whole library.
  2. "sideEffects": ["**/*.css"] tells the app's bundler that importing a JS module from this package has no side effects except the CSS files, so modules whose exports aren't used can be dropped entirely. Without it, bundlers must keep every imported module just in case.
  3. React is a peer dependency: the app supplies the one React instance. If the library bundled or depended on its own React, the app would load two copies and hooks would fail with "Invalid hook call". It's still a dev dependency so the library can build and test.
  4. files: ["dist"] publishes only the build output, and publishConfig.registry sends npm publish to a private registry for internal libraries (my notes: "publish the SDK to npm or create a private npm registry for internal distribution").
  5. version follows semver: a renamed prop is a major bump (3.0.0), a new optional prop a minor (2.5.0), a bug fix a patch (2.4.1). Tools like Changesets let each PR declare its bump and generate the changelog.

Updating my notes on tooling: they mention Webpack or Rollup for packaging. Rollup is still underneath most library builds, but in 2026 you'd usually reach for a wrapper — Vite's library mode, tsdown / tsup, or Rslib — which output ESM plus type declarations with a few lines of config. Webpack is rarely used for libraries now.

vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  build: {
    lib: { entry: "src/index.ts", formats: ["es"], fileName: "index" },
    rollupOptions: { external: ["react", "react-dom", "react/jsx-runtime"] },
  },
});
What’s happening
  1. build.lib switches Vite from app mode (an index.html entry, hashed chunks) to library mode: one entry file, output named index.js.
  2. formats: ["es"] emits only ES modules, which matches the exports map above. Add "cjs" only if you still have CommonJS consumers.
  3. external is essential: it stops React and the JSX runtime from being bundled into the library. Forgetting react/jsx-runtime is a classic way to ship a second copy of React.
  4. Type declarations come from tsc --emitDeclarationOnly or a plugin such as vite-plugin-dts. (Config not executed in this sandbox.)

Styling and theming the library

Correcting my notes slightly: they suggest styled-components if you need theming. That was the standard answer in 2020, but two things changed. Runtime CSS-in-JS doesn't work in React Server Components and adds style-injection work to every render, and styled-components entered maintenance mode in 2025 (its maintainer announced no new features). For a library today the common choice is plain CSS or CSS Modules with CSS custom properties for theming — they work in every framework, including Server Components — or a zero-runtime tool like vanilla-extract or Panda CSS.

ui.css
:root {
  --ui-color-accent: #2563eb;
  --ui-radius: 6px;
}
[data-theme="dark"] {
  --ui-color-accent: #60a5fa;
}
.ui-toggle {
  border-radius: var(--ui-radius);
  border: 1px solid var(--ui-color-accent);
}
.ui-toggle--on {
  background: var(--ui-color-accent);
  color: white;
}
What’s happening
  1. The library defines design tokens as CSS custom properties on :root: an accent colour and a radius.
  2. [data-theme="dark"] overrides the tokens. An app switches theme by setting data-theme="dark" on <html> — no React context, no re-render of every styled component.
  3. Component styles only reference tokens (var(--ui-color-accent)), so a brand can re-theme the whole library by overriding a handful of variables in its own CSS.
  4. This is the theming capability my notes attribute to styled-components' ThemeProvider, done with the platform instead of a runtime library.

#Storybook

Storybook is a workshop for UI components: a separate dev app where every component is shown in isolation, in every state you care about (primary, disabled, loading, error, long text), with controls to change its props live. Each of those states is a story. My notes put it simply: "Let's say you have a button — you create a storybook for it, for visual testing and looking at it."

The mental model: stories are examples that double as tests and docs. The same "disabled button" story is a page a designer reviews, a documentation entry generated from the component's props, an interaction test that clicks it, and a screenshot that a visual-regression tool compares on every PR. For a component library it's the documentation site (my notes: "Documenting library: Storybook").

Stories are written in Component Story Format (CSF): a default export describing the component, and named exports for each story. This is the CSF 3 format for Storybook 10 (the current major in 2026) with the React + Vite framework. Storybook isn't installed in my sandbox, so this file wasn't executed; it follows the official docs' format exactly.

Toggle.stories.tsx
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn } from "storybook/test";
import { Toggle } from "./Toggle";

const meta = {
  component: Toggle,
  tags: ["autodocs"], // generate a docs page from the props and stories
  args: { children: "Bold", onPressedChange: fn() },
} satisfies Meta<typeof Toggle>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Off: Story = {};

export const On: Story = { args: { defaultPressed: true } };

export const Disabled: Story = { args: { disabled: true } };

export const TogglesOnClick: Story = {
  play: async ({ canvas, userEvent, args }) => {
    const button = canvas.getByRole("button", { name: "Bold" });
    await userEvent.click(button);
    await expect(button).toHaveAttribute("aria-pressed", "true");
    await expect(args.onPressedChange).toHaveBeenCalledWith(true);
  },
};
What’s happening
  1. The default export meta names the component and default args (props) shared by every story. satisfies Meta<typeof Toggle> type-checks the args against Toggle's props while keeping the precise type for StoryObj<typeof meta>.
  2. fn() from storybook/test is a spy: the Actions panel logs every onPressedChange call, and tests can assert on it.
  3. Each named export is a story. Off uses only the defaults; On and Disabled override one arg. In the Storybook UI they appear as three entries under "Toggle", each with live controls for every prop.
  4. TogglesOnClick has a play function that runs after the story renders: it finds the button with Testing-Library-style queries on canvas, clicks it, and asserts the result. It's an interaction test that you can also watch step by step in the browser.
  5. tags: ["autodocs"] makes Storybook generate a documentation page with a props table (from the TypeScript types) and all the stories rendered live.

What Storybook gives a team, beyond "looking at it":

  • Development in isolation: build the error state of a component without breaking the backend to see it.
  • Interaction tests: with the Vitest addon (npx storybook add @storybook/addon-vitest), every story becomes a Vitest test run in a real browser, play functions included.
  • Visual tests: Chromatic (from the Storybook team) screenshots every story and flags pixel changes on each PR — the "visual testing" in my notes.
  • Accessibility checks: the a11y addon runs axe on every story.
  • Reuse in unit tests: composeStories from @storybook/react-vite turns stories into components you can render in React Testing Library, so test fixtures and stories don't drift apart.
Toggle.test.tsx
import { composeStories } from "@storybook/react-vite";
import { render, screen } from "@testing-library/react";
import * as stories from "./Toggle.stories";

const { On, Disabled } = composeStories(stories);

test("On story starts pressed", () => {
  render(<On />);
  expect(screen.getByRole("button", { name: "Bold" })).toHaveAttribute("aria-pressed", "true");
});

test("Disabled story is disabled", () => {
  render(<Disabled />);
  expect(screen.getByRole("button")).toBeDisabled();
});
What’s happening
  1. composeStories(stories) takes every story from the stories file and returns ready-to-render components with the meta's args, the story's args and any decorators already applied.
  2. <On /> renders Toggle with children: "Bold" and defaultPressed: true, so the button starts with aria-pressed="true".
  3. <Disabled /> renders it with disabled: true. The test reuses the story instead of repeating the props.
  4. Not executed here (needs @storybook/react-vite), but the equivalent assertions on Toggle itself are in this chapter's tests and pass.

#Micro-frontends

Micro-frontends apply the microservices idea to the browser: one product's frontend is split into several smaller apps, each owned, built and deployed independently by its own team, and composed into what the user sees as a single application. The checkout team can ship on Tuesday without waiting for the search team's release train.

The mental model is a shopping mall. Each shop (micro-frontend) has its own staff, stock and opening hours; the mall (the shell or host) provides the building, the corridors (routing and layout) and shared services (sign-in). Shoppers experience one place. The hard parts are the same as in a mall: shared rules (design system, versions), getting between shops (navigation and communication), and not letting one shop's flood (a crash) close the whole building.

My notes start with the architecture we had at work: "a combination of separate React web apps all in one mono repo". The apps used Redux and Redux-Saga to manage state and dispatch actions that ended up as GraphQL queries and mutations. GraphQL resolvers (which tell GraphQL how to find or change each piece of data) talked to an ESB — a wrapper around a message bus such as NATS or Kafka. On the other side of the bus, microservices listened for messages, did the actual work against existing services, and replied over the bus; the resolver got the response and GraphQL returned it to the React app. So the frontend was split into apps, and the backend into services behind a message bus.

There are three ways to compose the frontend pieces, all from my notes:

ApproachHow it worksProsCons
Server-side compositionthe server stitches each team's HTML fragments into one page before sending itfast first load; simple SEO since the page is server-renderedcross-fragment interaction is hard; deployments are coupled because the server orchestrates them
Client-side composition (runtime)a host app loads each micro-frontend's JavaScript in the browser, typically with Module Federationindependent development and deployment; no server orchestrationmore JavaScript after first load; more complex client logic for loading, routing and communication
iframeseach micro-frontend is a separate page inside an <iframe>strongest isolation: no CSS or JS conflicts, any tech stacklimited interaction; heavier on performance; communication via postMessage only

Client-side composition

From my notes, the host side of client-side composition — remote components loaded lazily:

host-app App.jsx (from my notes)
// In the host app (from my notes)
const Header = React.lazy(() => import("headerApp/Header"));
const Footer = React.lazy(() => import("footerApp/Footer"));

// In the App component
<Suspense fallback={<div>Loading...</div>}>
  <Header />
  <MainContent />
  <Footer />
</Suspense>;
What’s happening
  1. import("headerApp/Header") is not a file in the host's repo. Module Federation maps the headerApp prefix to a remote — another team's deployed build — and fetches its code at runtime (configuration in Module Federation in practice).
  2. React.lazy turns that dynamic import into a component that suspends until the code arrives; <Suspense> shows the fallback meanwhile.
  3. Because one <Suspense> wraps all three, the whole page shows "Loading..." until both remotes have loaded — a slow footer delays the header too. Give each remote its own <Suspense> so they appear independently.
  4. Nothing here handles a remote that's down: the import rejects, and with no error boundary the whole host crashes. Each remote should be wrapped in an error boundary as well; the next topic shows a tested version.

iframes and postMessage

From my notes, the iframe approach is just <iframe src="https://micro-frontend-1.com" title="Micro Frontend 1"> per micro-frontend. The work is in communication: frames share nothing, so they talk through window.postMessage, and every receiver must check event.origin, because any page can post a message to any window.

IframeHost.jsx
import { useEffect, useRef, useState } from "react";

const CART_ORIGIN = "https://cart.example.com";

export function IframeHost() {
  const frameRef = useRef(null);
  const [count, setCount] = useState(0);

  useEffect(() => {
    function onMessage(event) {
      if (event.origin !== CART_ORIGIN) return; // ignore messages from anyone else
      if (event.data?.type === "cart:updated") setCount(event.data.count);
    }
    window.addEventListener("message", onMessage);
    return () => window.removeEventListener("message", onMessage);
  }, []);

  function clearCart() {
    frameRef.current?.contentWindow?.postMessage({ type: "cart:clear" }, CART_ORIGIN);
  }

  return (
    <div>
      <p>Items in cart: {count}</p>
      <button onClick={clearCart}>Clear cart</button>
      <iframe ref={frameRef} src={`${CART_ORIGIN}/widget`} title="Cart micro-frontend" />
    </div>
  );
}
What’s happening
  1. The host renders the cart micro-frontend in an <iframe>. Its CSS and JavaScript can't leak into the host, and it could be written in any framework — the isolation my notes list as the main pro.
  2. An effect subscribes to message events on the host's window and removes the listener on unmount.
  3. When a message arrives, the handler first checks event.origin. A message from https://evil.example with { type: "cart:updated", count: 99 } is ignored; the same message from the cart's origin sets count, and the text becomes "Items in cart: 3". The test dispatches both.
  4. To talk the other way, clearCart posts to the iframe's contentWindow with the cart's origin as targetOrigin, so the browser delivers it only if the frame is still showing that origin.
  5. Everything crossing the boundary is a serialisable message — no shared React state, no shared store. That's the "limited interactions" con from my notes, and also why iframes are the safest option for untrusted or legacy apps.

Server-side composition

Server-side composition happens before React is involved: a server or CDN assembles the page from fragments that each team serves. Edge Side Includes (ESI) on a CDN or Server Side Includes in Nginx are the classic mechanisms:

layout.html
<body>
  <esi:include src="https://header.example.com/fragment" />
  <main><esi:include src="https://product.example.com/fragment?id=42" /></main>
  <esi:include src="https://footer.example.com/fragment" />
</body>
What’s happening
  1. The page template contains placeholders instead of content. Each src points at another team's service, which returns a ready-rendered HTML fragment (often produced with React's server rendering).
  2. The CDN or server fetches the fragments, splices them into the template and sends one complete HTML page — the fast first load and easy SEO my notes list as pros.
  3. Each fragment's JavaScript then hydrates its own part of the page in the browser. Coordination between fragments happens through the URL, custom DOM events or a tiny shared event bus.
  4. The cons from my notes show up here: a cross-fragment interaction needs that event plumbing, and the composing server is a shared piece every team's release depends on. (Frameworks like Next.js multi-zones are a modern variant: separate apps per URL path behind one domain.)

#Module Federation in practice

Module Federation (introduced in webpack 5, 2020) lets separately built and deployed apps share modules at runtime. A remote build exposes some modules (./Button) through a small manifest file, remoteEntry.js. A host build declares the remote by name and URL, and then imports from it like a package: import("RemoteApp/Button"). Both declare shared dependencies — React above all — so the page loads one copy that everyone uses.

The mental model is npm, but resolved at runtime instead of build time. With an npm package, the host gets the version it was built with; with a federated module, the host gets whatever the remote team deployed most recently, without rebuilding the host. That's the independent deployment micro-frontends promise — and the reason you need version discipline and error handling.

From my notes: "We used Webpack Module Federation in SMT / Command Center." Their walkthrough, step by step — remote first. (Neither this nor any config in this topic can run in my sandbox, which has no webpack or second server; they're checked against the official webpack and module-federation.io docs.)

remote-app/webpack.config.js (from my notes)
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const path = require("path");

module.exports = {
  entry: "./src/index.js",
  output: {
    publicPath: "http://localhost:3001/", // Remote app will run on port 3001
  },
  mode: "development",
  devServer: {
    port: 3001,
    contentBase: path.join(__dirname, "dist"),
    hot: true,
  },
  module: {
    rules: [{ test: /\.js$/, exclude: /node_modules/, use: "babel-loader" }],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "RemoteApp",
      filename: "remoteEntry.js",
      exposes: {
        "./Button": "./src/Button", // Expose the Button component
      },
      shared: {
        react: { singleton: true },
        "react-dom": { singleton: true },
      },
    }),
  ],
};
What’s happening
  1. name: "RemoteApp" is the container's name — the prefix the host will import from (RemoteApp/Button). It must be a valid JavaScript identifier, because by default it's also a global variable.
  2. filename: "remoteEntry.js" is the small entry file the host downloads first. It knows which chunks contain which exposed modules and negotiates shared dependencies.
  3. exposes: { "./Button": "./src/Button" } publishes the local file src/Button.js under the public name ./Button. Only exposed modules are reachable from outside.
  4. shared: { react: { singleton: true }, … } puts React into the shared scope and insists on one instance on the page. Two Reacts would break hooks ("Invalid hook call") and context. My notes: share React and ReactDOM "to ensure that both the host and remote apps use the same version, preventing duplication".
  5. output.publicPath tells webpack where the remote's chunks live, so the host can fetch them from port 3001. The remote's src/Button.js is just const Button = () => <button>Remote Button</button>; export default Button; and npm start serves it with remoteEntry.js at http://localhost:3001/remoteEntry.js.

The host declares the remote and imports from it:

host-app/webpack.config.js (from my notes, plugin part)
new ModuleFederationPlugin({
  name: "HostApp",
  remotes: {
    RemoteApp: "RemoteApp@http://localhost:3001/remoteEntry.js", // Load the remote entry from remote-app
  },
  shared: {
    react: { singleton: true },
    "react-dom": { singleton: true },
  },
});
host-app/src/App.js (from my notes)
import React from "react";

// Import the remote Button component
const RemoteButton = React.lazy(() => import("RemoteApp/Button"));

function App() {
  return (
    <div>
      <h1>Host App</h1>
      <React.Suspense fallback="Loading...">
        <RemoteButton />
      </React.Suspense>
    </div>
  );
}
export default App;
What’s happening
  1. In remotes, the key RemoteApp is the import prefix the host's code uses; the value RemoteApp@http://localhost:3001/remoteEntry.js is "container name @ URL of its entry file".
  2. When App first renders, React.lazy calls import("RemoteApp/Button"). Webpack sees the RemoteApp/ prefix, downloads remoteEntry.js from port 3001, initialises the shared scope (so the remote reuses the host's React), then fetches the chunk that contains ./Button.
  3. While that happens, RemoteButton suspends and <React.Suspense fallback="Loading..."> shows "Loading...".
  4. When the module arrives, its default export (the Button component) renders, and http://localhost:3000 shows "Remote Button" — "the Host App is using the component from Remote App without directly bundling or including it in its own build," as my notes put it.
  5. Deploy a new remote with a different button and the host shows it on the next page load, without a host rebuild.

Correcting my notes' config for 2026

The notes' example is the classic 2020–2021 tutorial and still shows the concepts correctly, but several details are outdated or will bite in production:

  • contentBase no longer exists: webpack-dev-server 4 replaced it with static (devServer: { static: path.join(__dirname, "dist") }). On webpack-dev-server 4+ (6 is current) the notes' config fails schema validation.
  • Import the plugin from the public API: const { ModuleFederationPlugin } = require("webpack").container; rather than the internal path webpack/lib/container/… (which works, but isn't a documented entry point).
  • Hard-coded publicPath only works on localhost. Use publicPath: "auto" so the remote works from whatever URL it's deployed to.
  • The async boundary: the host's entry must not import React synchronously, or you get "Uncaught Error: Shared module is not available for eager consumption". The webpack docs' fix is a bootstrap file — index.js contains only import("./bootstrap"); and bootstrap.js contains the createRoot(...).render(<App />) code — so webpack can initialise the shared scope before React is evaluated. (The alternative, eager: true, bundles the shared module into the entry and is discouraged.)
  • A remote that's down crashes the host. React.lazy rejects, and without an error boundary the error propagates to the root. Wrap every remote in its own boundary and Suspense (tested below).
  • TypeScript doesn't know RemoteApp/Button. Either declare it (declare module "RemoteApp/Button" { const Button: React.ComponentType; export default Button; }) or let Module Federation 2.0 generate and download the remote's types (below).

Module Federation 2.0: @module-federation/enhanced

Module Federation is now developed by the module-federation organisation (with the Rspack team) as Module Federation 2.0. The package @module-federation/enhanced (2.x in 2026) provides drop-in plugins for webpack and Rspack, plus a runtime API. It adds an mf-manifest.json (richer than remoteEntry.js), automatic type generation and download for remotes, a Chrome devtools extension, and runtime plugins. From the official docs, the webpack setup:

module-federation.config.js
module.exports = {
  name: "host",
  remotes: {
    provider: "provider@http://localhost:2004/mf-manifest.json",
  },
  shared: {
    react: { singleton: true },
    "react-dom": { singleton: true },
  },
};
webpack.config.js
const { ModuleFederationPlugin } = require("@module-federation/enhanced/webpack");
const mfConfig = require("./module-federation.config");

module.exports = {
  devServer: { port: 2000 },
  output: { publicPath: "http://localhost:2000/" },
  plugins: [new ModuleFederationPlugin(mfConfig)],
};
What’s happening
  1. The options are the same vocabulary as webpack's built-in plugin — name, remotes, exposes, shared — moved into their own module-federation.config.js so tools can read them.
  2. The remote URL points at mf-manifest.json instead of remoteEntry.js. The manifest describes the remote's exposes, shared dependencies and type archive, which enables preloading and type hints.
  3. The plugin comes from @module-federation/enhanced/webpack rather than webpack.container. For Rspack/Rsbuild there are equivalent plugins; for Vite, the @module-federation/vite package provides federation(mfConfig) with a config built by createModuleFederationConfig from the same package.
  4. With the dts option (on by default in the enhanced plugin), the remote publishes its TypeScript types and the host downloads them into @mf-types, so import("provider/Button") is typed without hand-written declarations.

When the list of remotes isn't known at build time — a dashboard whose widgets come from a server-side registry, say — the runtime API registers and loads remotes from code:

loadWidget.js
import { createInstance } from "@module-federation/enhanced/runtime";
import React from "react";

const mf = createInstance({
  name: "mf_host",
  remotes: [{ name: "remote1", alias: "remote-1", entry: "http://localhost:3001/mf-manifest.json" }],
});

export const MyButton = React.lazy(() =>
  mf.loadRemote("remote1").then(({ MyButton }) => ({ default: MyButton })),
);
What’s happening
  1. createInstance creates a federation runtime with no build plugin needed — the docs' "pure runtime" mode. Remotes are plain objects, so the list could come from a fetch.
  2. mf.registerRemotes([...]) can add more remotes later; with the build plugin you'd call the top-level registerRemotes and loadRemote imported from @module-federation/enhanced/runtime instead.
  3. mf.loadRemote("remote1") returns a promise of the remote module. React.lazy needs a module with a default export, so the .then maps the named export MyButton to default.
  4. The docs mark the older init() function "use with caution" (it reuses an existing instance with the same name) and point to createInstance for independent instances.

Loading a remote safely (tested)

The part of a federated host that can run anywhere is its failure handling. This RemoteSlot takes any loader — in the real host () => import("RemoteApp/Button") — and gives each remote its own Suspense and error boundary, with a retry:

RemoteSlot.jsx
import { lazy, Suspense, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";

export function RemoteSlot({ load, name }) {
  const [attempt, setAttempt] = useState(0);
  // A new lazy component per attempt: React.lazy caches a rejected import forever.
  const [Remote, setRemote] = useState(() => lazy(load));

  function retry() {
    setRemote(() => lazy(load));
    setAttempt((a) => a + 1);
  }

  return (
    <ErrorBoundary
      resetKeys={[attempt]}
      fallbackRender={() => (
        <div role="alert">
          {name} is unavailable. <button onClick={retry}>Retry</button>
        </div>
      )}
    >
      <Suspense fallback={<p>Loading {name}…</p>}>
        <Remote />
      </Suspense>
    </ErrorBoundary>
  );
}
What’s happening
  1. useState(() => lazy(load)) creates the lazy component once per mount (creating it during every render would restart loading each time). On first render, <Remote /> suspends and the user sees "Loading Header…".
  2. If the remote's server is down, the import promise rejects. React rethrows the error while rendering <Remote />, the nearest ErrorBoundary catches it, and only this slot shows "Header is unavailable. Retry" — the rest of the host keeps working.
  3. React.lazy remembers a rejected import and would throw the same error forever, so retry creates a fresh lazy(load) and bumps attempt.
  4. attempt is in the boundary's resetKeys; when it changes, the boundary clears its error and renders its children again. The new lazy component calls load() a second time.
  5. The test's loader rejects on the first call and resolves with { default: () => <button>Remote Button</button> } on the second: it sees the alert, clicks Retry, and then finds "Remote Button". React logs the caught load error with console.error (silenced in the test).

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.