Learning path · Level 10 · Tutorial 3

Organize React Features by Responsibility

Organize a small routed application around features and clear responsibilities. Separate view composition, application rules, and typed data access without introducing unnecessary abstraction layers.

  • Level: Intermediate
  • Duration: 18 min
  • 8 steps
Start tutorial Back to the path
Organize React Features by Responsibility

What you will go through

  1. Identify Responsibilities in a Working Feature Identify the mixed responsibilities in a working Trail Shop products feature and record the behavior that later refactoring must preserve. 3 min
  2. Map the Feature to a Small Module Structure Choose a small feature-focused module structure for Trail Shop products before extracting code. 2 min
  3. Extract Pure Product Rules Extract the typed availability-selection rule into a product-specific domain module while leaving routing, rendering, and data access outside the rule. 2 min
  4. Put Typed Data Access Behind One Operation Separate transport and validation details from route orchestration without changing request behavior. 2 min
  5. Give Presentation Components Data and Callbacks Define presentation contracts that receive typed data and semantic callbacks while route composition retains routing and data-access work. 2 min
  6. Reconnect the Routes as Composition Boundaries Reconnect extracted product modules through route composition while preserving URL behavior, request orchestration, native links, and page-level ownership. 3 min
  7. Define the Feature's Public Boundary Expose the products feature through a small entry point and audit import direction without adding speculative shared layers. 2 min
  8. Verify the Refactor Without Changing Behavior Finish and audit the local refactor, repeat the baseline checks, and explain the resulting responsibility boundaries. 3 min

What you will learn

  • Classify application code as route composition, presentation, domain logic, or data access.
  • Refactor a feature into modules whose imports follow clear responsibility boundaries.
  • Give presentation components typed data and callback props instead of transport dependencies.
  • Repeat the same local navigation and interaction checks before and after the refactor.

Before you start

  • Represent Detail Views and Filters in the URL
  • Split Browser Code into JavaScript Modules
  • Compose Interfaces with Typed Props
  • Validate Unknown Data at Typed API Boundaries

Step 1 of 8

Identify Responsibilities in a Working Feature

Identify the mixed responsibilities in a working Trail Shop products feature and record the behavior that later refactoring must preserve.

See the responsibilities in the starter

Name the jobs before moving code

A working feature can temporarily place several jobs in one module. That is not automatically a bug—but unrelated changes become harder to isolate.

Use these four names as you inspect the Trail Shop starter:

  • Route composition coordinates route inputs, request state, and page-level choices.
  • Presentation renders supplied information and controls.
  • Domain logic expresses an application rule, such as which products qualify for a filter.
  • Data access obtains records and validates them before the feature uses them.

First observe and name these jobs. Do not refactor yet.

Four responsibilities in one working feature

Diagram showing route coordination, interface rendering, product-selection rules, and validated JSON retrieval as distinct jobs.

The jobs cooperate, but answer different questions: where the page is, what it renders, which records qualify, and how records become trustworthy.

Responsibility guide

A module may contain all four responsibilities at first. Classify code by what it does, not by the filename it currently has.

The starter is deliberately mixed so you can observe the boundaries before extracting them.
ResponsibilityOwnsRepresentative operation
Route compositionPage coordinationRead a search parameter and choose a page state
PresentationSupplied interface outputRender a product name and stock label
Domain logicProduct-specific ruleKeep only records that are in stock
Data accessTransport and validationFetch JSON and reject an invalid record

Add the shell and fixture

Create the local working baseline

Create these four files in your local Vite React project. Everything needed for the starter is shown as copyable text; there is no download or hosted project.

The starter deliberately keeps several responsibilities in src/ProductRoutes.tsx. It is a working before state, not a model to copy into future features.

src/main.tsx

src/main.tsx

tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { App } from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>
);

src/App.tsx

src/App.tsx

tsx
import { useEffect, useRef } from 'react';
import { Link, Route, Routes, useLocation } from 'react-router';
import { ProductDetailRoute, ProductListRoute } from './ProductRoutes';

export function App() {
  const location = useLocation();
  const headingRef = useRef<HTMLHeadingElement>(null);

  useEffect(() => {
    document.title = location.pathname === '/products'
      ? 'Products | Trail Shop'
      : 'Trail Shop';
    headingRef.current?.focus();
  }, [location.pathname]);

  return (
    <>
      <header>
        <Link to='/products'>Products</Link>
      </header>
      <main>
        <h1 ref={headingRef} tabIndex={-1}>Trail Shop</h1>
        <Routes>
          <Route path='/products' element={<ProductListRoute />} />
          <Route path='/products/:productId' element={<ProductDetailRoute />} />
          <Route path='*' element={<p>Page not found.</p>} />
        </Routes>
      </main>
    </>
  );
}

public/products.json

public/products.json

json
[
  { "id": "p1", "name": "Trail Mug", "inStock": true },
  { "id": "p2", "name": "Rain Cap", "inStock": false },
  { "id": "p3", "name": "Day Pack", "inStock": true }
]

Keep the baseline mixed, but runnable

Paste the mixed route module

Create src/ProductRoutes.tsx by pasting Parts 1–6 below consecutively into one file, in order. The routes intentionally duplicate request orchestration for now: each route directly owns its state, Effect, retry trigger, and AbortController cleanup.

That duplication is temporary evidence of mixed responsibilities—not a custom Hook. Keep this starter unchanged while you record the baseline.

Imports, contracts, and validation

src/ProductRoutes.tsx — Part 1 of 6

tsx
import { useEffect, useState } from 'react';
import { Link, useParams, useSearchParams } from 'react-router';

type Product = {
  id: string;
  name: string;
  inStock: boolean;
};

type AvailabilityFilter = 'all' | 'in-stock';

function parseProducts(value: unknown): Product[] {
  if (!Array.isArray(value)) {
    throw new Error('Invalid product data.');
  }

  return value.map((item): Product => {
    if (typeof item !== 'object' || item === null) {
      throw new Error('Invalid product data.');
    }

    const product = item as Record<string, unknown>;
    if (
      typeof product.id !== 'string' ||
      typeof product.name !== 'string' ||
      typeof product.inStock !== 'boolean'
    ) {
      throw new Error('Invalid product data.');
    }

    return {
      id: product.id,
      name: product.name,
      inStock: product.inStock
    };
  });
}

List route request orchestration

Append to src/ProductRoutes.tsx — Part 2 of 6

tsx
export function ProductListRoute() {
  const [products, setProducts] = useState<Product[] | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [retryVersion, setRetryVersion] = useState(0);
  const [params, setParams] = useSearchParams();

  useEffect(() => {
    const controller = new AbortController();

    async function load(): Promise<void> {
      setProducts(null);
      setError(null);

      try {
        const response = await fetch('/products.json', {
          signal: controller.signal
        });

        if (!response.ok) {
          throw new Error('Could not load products.');
        }

        const value: unknown = await response.json();
        setProducts(parseProducts(value));
      } catch (reason: unknown) {
        if (reason instanceof DOMException && reason.name === 'AbortError') {
          return;
        }

        setError(
          reason instanceof Error ? reason.message : 'Could not load products.'
        );
      }
    }

    void load();
    return () => controller.abort();
  }, [retryVersion]);

List route URL, rule, and states

Append to src/ProductRoutes.tsx — Part 3 of 6

tsx
  const filter: AvailabilityFilter = params.get('availability') === 'in-stock'
    ? 'in-stock'
    : 'all';

  if (error) {
    return <><h2>Products</h2><p>{error}</p><button onClick={() => setRetryVersion((current) => current + 1)}>Retry</button></>;
  }

  if (!products) {
    return <><h2>Products</h2><p>Loading products…</p></>;
  }

  const visibleProducts = filter === 'in-stock'
    ? products.filter((product) => product.inStock)
    : products;
  const productSearch = params.toString();

List route rendering and URL update

Append to src/ProductRoutes.tsx — Part 4 of 6

tsx
  return (
    <section>
      <h2>Products</h2>
      <label>
        Availability
        <select
          value={filter}
          onChange={(event) => {
            const value: AvailabilityFilter = event.currentTarget.value === 'in-stock'
              ? 'in-stock'
              : 'all';
            setParams((current) => {
              const next = new URLSearchParams(current);
              if (value === 'all') {
                next.delete('availability');
              } else {
                next.set('availability', value);
              }
              return next;
            });
          }}
        >
          <option value='all'>All products</option>
          <option value='in-stock'>In stock only</option>
        </select>
      </label>
      <ul>
        {visibleProducts.map((product) => (
          <li key={product.id}>
            <Link to={`/products/${product.id}${productSearch ? `?${productSearch}` : ''}`}>
              {product.name}
            </Link>
            {' — '}{product.inStock ? 'In stock' : 'Out of stock'}
          </li>
        ))}
      </ul>
    </section>
  );
}

Detail route request orchestration

Append to src/ProductRoutes.tsx — Part 5 of 6

tsx
export function ProductDetailRoute() {
  const { productId } = useParams();
  const [products, setProducts] = useState<Product[] | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [retryVersion, setRetryVersion] = useState(0);

  useEffect(() => {
    const controller = new AbortController();

    async function load(): Promise<void> {
      setProducts(null);
      setError(null);

      try {
        const response = await fetch('/products.json', {
          signal: controller.signal
        });

        if (!response.ok) {
          throw new Error('Could not load products.');
        }

        const value: unknown = await response.json();
        setProducts(parseProducts(value));
      } catch (reason: unknown) {
        if (reason instanceof DOMException && reason.name === 'AbortError') {
          return;
        }

        setError(
          reason instanceof Error ? reason.message : 'Could not load products.'
        );
      }
    }

    void load();
    return () => controller.abort();
  }, [retryVersion]);

Detail route outcomes and rendering

Append to src/ProductRoutes.tsx — Part 6 of 6

tsx
  if (!productId || !/^p\d+$/.test(productId)) {
    return <><h2>Products</h2><p>Invalid product identifier.</p><Link to='/products'>Products</Link></>;
  }

  if (error) {
    return <><h2>Products</h2><p>{error}</p><button onClick={() => setRetryVersion((current) => current + 1)}>Retry</button></>;
  }

  if (!products) {
    return <><h2>Products</h2><p>Loading products…</p></>;
  }

  const product = products.find((item) => item.id === productId);
  if (!product) {
    return <><h2>Products</h2><p>Product not found.</p><Link to='/products'>Products</Link></>;
  }

  return (
    <section>
      <h2>{product.name}</h2>
      <p>{product.inStock ? 'In stock' : 'Out of stock'}</p>
      <Link to='/products'>Products</Link>
    </section>
  );
}

Establish the baseline and check the map

Record observable behavior before refactoring

Run your usual local development server and record this Before checklist without editing the code. You will repeat the same checks after the later extractions.

For filter-only changes, focus should remain on the select control. For page navigation, the application’s established title and heading-focus behavior should occur. Record what your own browser does in the After column later; a successful type check does not prove these interactions.

Before/After browser checklist

Use the fixed Trail Shop fixture and the same URLs before and after the refactor.

The behavior—not the current file layout—is what later refactoring must preserve.
CheckBeforeAfter
Open /products?source=practiceThree products appear
Choose In stock onlyTrail Mug and Day Pack appear; source=practice remains
Activate Trail Mug from that filtered listDetails appear at /products/p1?source=practice&availability=in-stock
Open /products/p1Matching product details appear
Visit /products/p9Well-formed missing-product state appears
Visit /products/nopeInvalid-identifier state appears
Use Tab and Enter on a product linkNative link behavior, including opening another tab
Use browser Back and ForwardNavigation history works
Change only the filterFilter retains focus; page title and heading focus do not change
Navigate between pagesEstablished page title and heading-focus behavior occurs

Classify the mixed operations

After recording the checklist, match each representative starter operation to its responsibility.

Tap an item and then its matching pair.

Step 2 of 8

Map the Feature to a Small Module Structure

Choose a small feature-focused module structure for Trail Shop products before extracting code.

Feature first, responsibility within it

Keep the feature together

A feature-focused folder keeps the code that changes together. For Trail Shop, routes, views, contracts, rules, and data access for products belong under src/features/products/.

This is not a project-wide components/, utils/, or services/ reorganization. The product feature remains the context for each module, while each file has one primary responsibility.

One feature, several responsibilities

A products feature folder groups route composition, presentation, shared types, domain logic, and data access modules.

Organize by feature first; separate responsibilities inside that feature.

Read the target file map

Products feature map

Target structure under src/

shell
features/
  products/
    ProductListRoute.tsx
    ProductDetailRoute.tsx
    ProductViews.tsx
    productTypes.ts
    productRules.ts
    productsApi.ts
    index.ts

A deliberately small tree

Use .tsx for the three modules that contain JSX. Use .ts for contracts, rules, data access, and the feature entry point.

ProductListRoute.tsx and ProductDetailRoute.tsx hold route composition. ProductViews.tsx groups the small, related presentation components ProductFilter and ProductDetails; separation by responsibility does not require one file per component.

The remaining files make product-specific responsibilities explicit without creating extra layers.

productTypes.ts

Shared product contracts belong in one feature-local module.

typescript
export type Product = {
  id: string;
  name: string;
  inStock: boolean;
};

export type AvailabilityFilter = 'all' | 'in-stock';

Preserve boundaries during extraction

Names need matching contents

A filename is only a promise; its contents and imports must keep that promise.

productTypes.ts contains shared product contracts. It must not import React, React Router, or transport code. productRules.ts remains product-specific even though it will contain a plain function. productsApi.ts keeps fixture reading and validation together. ProductViews.tsx renders supplied values and callbacks rather than taking over route or transport work.

Create the files as a map for the extraction. Keep the working starter route code in place until the specific dependency it needs has been moved and is ready to import. Do not point imports at empty or unfinished modules.

Tip

Avoid a premature global utilities folder

A helper being useful in more than one products module does not make it globally useful. Keep it local to features/products while its inputs, types, and meaning are specific to products. Move code beyond the feature only when multiple features have a concrete shared need.

Apply the feature map

Plan the module locations

You are planning the next extraction from the working starter. Assign each responsibility below to one target file, then explain the folder decision.

  • Shared Product and AvailabilityFilter contracts
  • The availability-selection rule
  • Reading and validating public/products.json
  • Rendering the filter and product details from supplied props
  • Coordinating each routed page

Why should the availability helper stay inside features/products rather than move now to a global utils folder?

Write at least 180 characters (0/180).

Step 3 of 8

Extract Pure Product Rules

Extract the typed availability-selection rule into a product-specific domain module while leaving routing, rendering, and data access outside the rule.

Define a pure responsibility

Keep the rule focused

A domain logic rule answers a product question from explicit inputs. Here, the question is: which supplied products are visible for a supported availability filter?

selectVisibleProducts receives product records and an AvailabilityFilter, then returns the records that should be visible. It does not need JSX, a route parameter, component state, URLSearchParams, the DOM, or a request. Route composition supplies the inputs; presentation renders the result.

This makes the availability decision inspectable and changeable without carrying page-specific behavior with it.

Inputs and output of the rule

A diagram showing three product records and an availability filter entering a pure product-selection rule, with all records or only the two in-stock records leaving it. URL, interface, network, and DOM concerns sit outside the rule.

The rule transforms supplied product data. It does not obtain, interpret URLs for, or render that data.

Place the rule with the feature

Extract the typed selection rule

Create src/features/products/productRules.ts. Its only dependency is the feature-local contract module created in the previous step.

typescript
import type { AvailabilityFilter, Product } from './productTypes';

export function selectVisibleProducts(
  products: Product[],
  filter: AvailabilityFilter
): Product[] {
  if (filter === 'in-stock') {
    return products.filter((product) => product.inStock);
  }

  return products;
}

Tip

Check the fixed results

With the fixed Trail Shop fixture, all returns the three records p1, p2, and p3. in-stock returns p1 and p3. Filtering creates the selected result without changing any input record.

Although this is a plain function, it is product-specific domain logic. Keep it in features/products rather than moving it to a shared utilities folder without a concrete cross-feature need.

Use the rule without moving other work

Let route composition supply inputs

Extract the contracts and rule first while the starter routes remain in src/ProductRoutes.tsx. Do not move fetching, validation, URL interpretation, or rendering in this step.

Remove the local Product and AvailabilityFilter declarations from src/ProductRoutes.tsx. Add the two feature imports below its existing React and React Router imports; keep the existing parseProducts function directly below the complete import block. Then replace only the list route’s inline visibleProducts conditional with the rule call.

The existing search-parameter update remains untouched, so its established behavior—including preserving source=practice—does not change.

Import the contracts and rule

Add these imports after the existing React and React Router imports in src/ProductRoutes.tsx.

tsx
import type {
  AvailabilityFilter,
  Product
} from './features/products/productTypes';
import { selectVisibleProducts } from './features/products/productRules';

Delegate product selection

Replace the existing multi-line conditional that assigns visibleProducts in ProductListRoute with this declaration. The existing JSX can continue mapping visibleProducts.

tsx
const visibleProducts = selectVisibleProducts(products, filter);

Apply the product rule

Complete and audit the pure boundary

Complete the rule’s explicit input contracts, its use from the route caller, and the Router import that would incorrectly couple this domain module to route composition.

typescript
import type { ,  } from './productTypes';

export function selectVisibleProducts(
  products: Product[],
  filter: AvailabilityFilter
): Product[] {
  return filter === 'in-stock'
    ? products.filter((product) => product.inStock)
    : products;
}

// In ProductListRoute, after URL interpretation:
const visibleProducts = (products, filter);

// Boundary audit: productRules.ts must not import  from 'react-router'.

Step 4 of 8

Put Typed Data Access Behind One Operation

Separate transport and validation details from route orchestration without changing request behavior.

Hide transport details, not failures

One operation at the data boundary

A route needs product records, but it should not need to know the fixture path, inspect an HTTP response, decode JSON, and validate each record. Put those transport details behind one typed operation.

getProducts is the data-access boundary. Its consumer receives either a Promise<Product[]> or a rejection. It does not receive a Response, raw JSON, or a misleading empty array after a failure.

Keep transport details behind the boundary

Diagram showing a route calling a data-access module, which reads JSON, checks the response, validates records, and returns typed product data.

The route calls one operation; request and validation details stay together in the data-access module.

Keep validation with the request

Create the typed data-access operation

Create src/features/products/productsApi.ts. Move the existing parseProducts implementation from src/ProductRoutes.tsx into this file. The fixture remains a local, read-only JSON file; this module does not turn it into a writable API.

typescript
import type { Product } from './productTypes';

export async function getProducts(
  signal?: AbortSignal
): Promise<Product[]> {
  const response = await fetch('/products.json', { signal });

  if (!response.ok) {
    throw new Error('Could not load products.');
  }

  const value: unknown = await response.json();
  return parseProducts(value);
}

function parseProducts(value: unknown): Product[] {
  if (!Array.isArray(value)) {
    throw new Error('Invalid product data.');
  }

  return value.map((item): Product => {
    if (typeof item !== 'object' || item === null) {
      throw new Error('Invalid product data.');
    }

    const product = item as Record<string, unknown>;
    if (
      typeof product.id !== 'string' ||
      typeof product.name !== 'string' ||
      typeof product.inStock !== 'boolean'
    ) {
      throw new Error('Invalid product data.');
    }

    return {
      id: product.id,
      name: product.name,
      inStock: product.inStock
    };
  });
}

Warning

A typed promise is not runtime validation

Promise<Product[]> describes the successful result for TypeScript; it cannot prove that decoded JSON has that shape at runtime. Keep parseProducts beside the request so invalid data rejects before it reaches a route.

Do not catch a request, decoding, or validation failure here just to return []. An empty successful result means something different from a failed request. Rejections let the route preserve its existing error message and Retry action.

Reconnect the existing route state

Keep request orchestration in each route

The extraction changes the data boundary, not route ownership. ProductListRoute and ProductDetailRoute still create their own AbortController, clear and set their own loading/error state, use retryVersion, render Retry, and abort during Effect cleanup.

In src/ProductRoutes.tsx, remove the local parseProducts function after creating productsApi.ts. Then replace the route module's import block with the following imports.

Update the intermediate route imports

Replace the imports at the top of src/ProductRoutes.tsx. This remains an intermediate route module until the later route-composition extraction.

typescript
import { useEffect, useState } from 'react';
import { Link, useParams, useSearchParams } from 'react-router';
import type {
  AvailabilityFilter,
  Product
} from './features/products/productTypes';
import { selectVisibleProducts } from './features/products/productRules';
import { getProducts } from './features/products/productsApi';

Replace each route's request Effect

The two routes already have the same request pattern. In both ProductListRoute and ProductDetailRoute, replace the existing useEffect with this version. It removes direct fetch, response handling, JSON decoding, and validator calls while retaining the route-local state and cleanup.

Route-local Effect using getProducts

Paste this in place of each existing request useEffect in src/ProductRoutes.tsx. Keep the surrounding products, error, and retryVersion state declarations and all existing rendering branches unchanged.

tsx
useEffect(() => {
  const controller = new AbortController();

  async function load(): Promise<void> {
    setProducts(null);
    setError(null);

    try {
      const nextProducts = await getProducts(controller.signal);
      setProducts(nextProducts);
    } catch (reason: unknown) {
      if (reason instanceof DOMException && reason.name === 'AbortError') {
        return;
      }

      setError(
        reason instanceof Error ? reason.message : 'Could not load products.'
      );
    }
  }

  void load();
  return () => controller.abort();
}, [retryVersion]);

Responsibility check

The refactor changes where each concern lives, not the observable request behavior.

The route consumes typed products; productsApi.ts owns transport and validation.
ConcernBeforeAfter
Fixture path and fetch callMixed into each routeproductsApi.ts
HTTP check and JSON decodingMixed into each routeproductsApi.ts
Runtime record validationMixed into each routeproductsApi.ts
Loading, error, and Retry stateRouteRoute
AbortController lifetime and cleanupRouteRoute

Practice: preserve the ownership split

Complete the data-access boundary

Complete the boundary so transport validation stays in productsApi.ts and the route retains cancellation ownership.

typescript
// productsApi.ts
export async function getProducts(
  ?: AbortSignal
): Promise<Product[]> {
  const response = await fetch('/products.json', { signal });

  if (!) {
    throw new Error('Could not load products.');
  }

  const value: unknown = await ;
  return ;
}

// Inside a route Effect that created controller
const nextProducts = await getProducts();

Step 5 of 8

Give Presentation Components Data and Callbacks

Define presentation contracts that receive typed data and semantic callbacks while route composition retains routing and data-access work.

Draw a narrow presentation boundary

Props are the presentation boundary

A presentation component receives what it needs to render and reports a user intention through props. It does not decide how the URL changes or where product records come from.

For this feature, ProductFilter receives the selected AvailabilityFilter plus an onChange callback. It turns a native select event into that semantic value. ProductDetails receives one resolved product and renders it.

The route composition layer still reads the URL, requests data, applies rules, and decides what to do when the callback runs.

Keep the dependencies one-way

Diagram showing route composition supplying typed product data and a callback to presentation components, while URL and network access remain outside presentation.

Presentation renders supplied inputs and sends semantic user choices back to its caller.

Tip

Keep links native and route-owned

Keep navigation markup with the route when it expresses a routing decision. For example, the list route can map visible products to real Link elements. Those links keep keyboard activation, browser history, and opening a destination in another tab.

Extracting ProductFilter and ProductDetails does not mean every piece of JSX must move. A route may retain lightweight layout and its route-owned links.

Implement the view contracts

Presentation components with typed props

Create src/features/products/ProductViews.tsx. It imports only feature contracts and renders supplied values. The select translates its native string value into the supported callback union; it does not inspect search parameters.

tsx
import type { AvailabilityFilter, Product } from './productTypes';

type ProductFilterProps = {
  value: AvailabilityFilter;
  onChange: (value: AvailabilityFilter) => void;
};

type ProductDetailsProps = {
  product: Product;
};

export function ProductFilter({ value, onChange }: ProductFilterProps) {
  return (
    <label>
      Availability
      <select
        value={value}
        onChange={(event) => {
          const nextValue = event.currentTarget.value;

          if (nextValue === 'all' || nextValue === 'in-stock') {
            onChange(nextValue);
          }
        }}
      >
        <option value="all">All products</option>
        <option value="in-stock">In stock only</option>
      </select>
    </label>
  );
}

export function ProductDetails({ product }: ProductDetailsProps) {
  return (
    <section>
      <h2>{product.name}</h2>
      <p>{product.inStock ? 'In stock' : 'Out of stock'}</p>
    </section>
  );
}

The component does not need the parent’s plan

ProductViews.tsx has no import from productsApi.ts and no React Router import. Its props make the dependencies visible: a filter value, a callback request, and a product to display.

A parent route can pass onChange that updates availability while preserving source=practice. That URL work stays outside this file.

Practice: type the component inputs

Complete the presentation contracts

Assume the feature types are already available in this file. Complete the two prop annotations.

tsx
type ProductFilterProps = {
  value: 'all' | 'in-stock';
  onChange: (value: ) => void;
};

type ProductDetailsProps = {
  product: ;
};

Practice: reject boundary violations

Audit the presentation dependency

Which import set belongs in ProductViews.tsx?

Step 6 of 8

Reconnect the Routes as Composition Boundaries

Reconnect extracted product modules through route composition while preserving URL behavior, request orchestration, native links, and page-level ownership.

Use routes to connect existing responsibilities

Routes compose existing responsibilities

A route component is a composition boundary: it connects route inputs, route-owned request state, domain logic, and presentation. It does not absorb the implementation of those responsibilities.

ProductListRoute interprets availability, calls selectVisibleProducts, and turns ProductFilter’s semantic callback into the existing search-parameter update. ProductDetailRoute interprets productId, keeps invalid and missing-record outcomes distinct, and supplies a resolved product to ProductDetails.

Moving these components into features/products changes module ownership only. The routes still own their existing Effect, retry state, cancellation cleanup, loading and error branches, and route-owned Link elements. App.tsx still owns route registration, page title updates, and page-navigation focus.

Composition, not a new layer

Diagram of route components importing product views, product rules, and data access while receiving routing APIs; the application shell is outside the feature boundary.

Import direction: App.tsx reaches the feature through its entry point later. Routes directly import sibling views, rules, and data access; arrows mean imports, not execution order.

Keep ownership stable

The refactor relocates code without changing the established owner of each behavior.

Responsibility boundaries after the route reconnection.
ResponsibilityOwner after reconnection
Route composition, request state, retry, cleanupProductListRoute or ProductDetailRoute
PresentationProductViews.tsx
Domain logicproductRules.ts
Data access and validationproductsApi.ts
Route registration, page title, page focusApp.tsx

Reconnect the list route without URL regressions

Reconnect the list route

Create src/features/products/ProductListRoute.tsx by pasting Parts 1 and 2 consecutively, in that order. This replaces the list-route implementation from the intermediate src/ProductRoutes.tsx; it does not change its request lifecycle.

The route accepts missing or unsupported availability values as all. When the filter changes to all, it deletes that parameter exactly as the starter did. It copies the current parameters before changing the filter, so source=practice remains. Product links keep the current search string when they open a detail route.

List route imports and request orchestration

src/features/products/ProductListRoute.tsx — Part 1 of 2

tsx
import { useEffect, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { ProductFilter } from './ProductViews';
import { getProducts } from './productsApi';
import { selectVisibleProducts } from './productRules';
import type { AvailabilityFilter, Product } from './productTypes';

export function ProductListRoute() {
  const [products, setProducts] = useState<Product[] | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [retryVersion, setRetryVersion] = useState(0);
  const [params, setParams] = useSearchParams();

  useEffect(() => {
    const controller = new AbortController();

    async function load(): Promise<void> {
      setProducts(null);
      setError(null);

      try {
        const nextProducts = await getProducts(controller.signal);
        setProducts(nextProducts);
      } catch (reason: unknown) {
        if (reason instanceof DOMException && reason.name === 'AbortError') {
          return;
        }

        setError(
          reason instanceof Error ? reason.message : 'Could not load products.'
        );
      }
    }

    void load();
    return () => controller.abort();
  }, [retryVersion]);

List route URL and presentation wiring

src/features/products/ProductListRoute.tsx — Part 2 of 2

tsx
  const filter: AvailabilityFilter = params.get('availability') === 'in-stock'
    ? 'in-stock'
    : 'all';

  function handleFilterChange(value: AvailabilityFilter): void {
    setParams((current) => {
      const next = new URLSearchParams(current);
      if (value === 'all') {
        next.delete('availability');
      } else {
        next.set('availability', value);
      }
      return next;
    });
  }

  if (error) {
    return <><h2>Products</h2><p>{error}</p><button onClick={() => setRetryVersion((current) => current + 1)}>Retry</button></>;
  }

  if (!products) {
    return <><h2>Products</h2><p>Loading products…</p></>;
  }

  const visibleProducts = selectVisibleProducts(products, filter);
  const productSearch = params.toString();

  return (
    <section>
      <h2>Products</h2>
      <ProductFilter value={filter} onChange={handleFilterChange} />
      <ul>
        {visibleProducts.map((product) => (
          <li key={product.id}>
            <Link to={`/products/${product.id}${productSearch ? `?${productSearch}` : ''}`}>
              {product.name}
            </Link>
            {' — '}{product.inStock ? 'In stock' : 'Out of stock'}
          </li>
        ))}
      </ul>
    </section>
  );
}

Preserve distinct detail outcomes

Reconnect the detail route

Create src/features/products/ProductDetailRoute.tsx by pasting these two parts consecutively. The request Effect remains route-local and passes its AbortSignal to getProducts.

The identifier check remains before a lookup: nope is malformed and shows the invalid-identifier branch. A well-formed identifier such as p9 can load successfully but still have no matching record, so it shows the not-found branch. A matching p1 is passed as typed data to ProductDetails.

Detail route imports and request orchestration

src/features/products/ProductDetailRoute.tsx — Part 1 of 2

tsx
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { ProductDetails } from './ProductViews';
import { getProducts } from './productsApi';
import type { Product } from './productTypes';

export function ProductDetailRoute() {
  const { productId } = useParams();
  const [products, setProducts] = useState<Product[] | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [retryVersion, setRetryVersion] = useState(0);

  useEffect(() => {
    const controller = new AbortController();

    async function load(): Promise<void> {
      setProducts(null);
      setError(null);

      try {
        const nextProducts = await getProducts(controller.signal);
        setProducts(nextProducts);
      } catch (reason: unknown) {
        if (reason instanceof DOMException && reason.name === 'AbortError') {
          return;
        }

        setError(
          reason instanceof Error ? reason.message : 'Could not load products.'
        );
      }
    }

    void load();
    return () => controller.abort();
  }, [retryVersion]);

Detail route outcomes and typed presentation

src/features/products/ProductDetailRoute.tsx — Part 2 of 2

tsx
  if (!productId || !/^p\d+$/.test(productId)) {
    return <><h2>Products</h2><p>Invalid product identifier.</p><Link to='/products'>Products</Link></>;
  }

  if (error) {
    return <><h2>Products</h2><p>{error}</p><button onClick={() => setRetryVersion((current) => current + 1)}>Retry</button></>;
  }

  if (!products) {
    return <><h2>Products</h2><p>Loading products…</p></>;
  }

  const product = products.find((item) => item.id === productId);
  if (!product) {
    return <><h2>Products</h2><p>Product not found.</p><Link to='/products'>Products</Link></>;
  }

  return (
    <>
      <ProductDetails product={product} />
      <Link to='/products'>Products</Link>
    </>
  );
}

Integrate in dependency order

Complete the route wiring

Complete the direct sibling imports and the value/callback wiring for the list route.

tsx
import { ProductFilter } from '';
import type { AvailabilityFilter } from '';
import { selectVisibleProducts } from '';

// Inside the loaded-success branch of ProductListRoute:
return (
  <ProductFilter value={} onChange={} />
);

Order the safe integration changes

Arrange these integration changes from first to last so no caller depends on a missing module.

  1. Add explicit exports for the completed route modules in features/products/index.ts.
  2. Confirm that no imports still use ProductRoutes.tsx, then remove the obsolete starter route module.
  3. Create ProductListRoute.tsx and ProductDetailRoute.tsx with the supplied direct sibling imports, route-owned state, Effects, and presentation wiring.
  4. Change App.tsx to import the route components from './features/products' while retaining route registration and page-level behavior.

Step 7 of 8

Define the Feature's Public Boundary

Expose the products feature through a small entry point and audit import direction without adding speculative shared layers.

Expose routes, not internals

A small public surface

The application shell is the feature’s current consumer. It needs route components—not validators, fetch details, or presentation helpers. Make that small contract visible in the feature entry point, then let App.tsx import from it.

Explicit route exports

src/features/products/index.ts is the feature entry point.

typescript
export { ProductListRoute } from './ProductListRoute';
export { ProductDetailRoute } from './ProductDetailRoute';

Shell import

The application shell consumes the entry point rather than individual feature files.

typescript
import {
  ProductDetailRoute,
  ProductListRoute,
} from './features/products';

Import direction

A dependency diagram showing App importing the products entry point, which exposes route modules; routes import internal views, rules, API access, and types.

In this diagram, A → B means “A imports B.” The entry point is the boundary between the application shell and feature internals.

Keep dependency directions clear

Audit imports by responsibility

The folder name is not the boundary by itself. Inspect imports: each file should import only what its responsibility needs. Routes compose; views render; rules decide; data access reads and validates; types define contracts.

Allowed dependencies

Use this as an import audit, not as a reason to add layers.

Feature modules import siblings directly; they do not route internal imports through `index.ts`.
ModuleMay importMust not import
Route modulesViews, rules, data access, types, React RouterTransport implementation details outside products
ProductViews.tsxFeature types and rendering dependenciesReact Router, productsApi.ts
productRules.tsFeature typesReact, React Router, productsApi.ts
productsApi.tsFeature typesReact, React Router, view modules
productTypes.tsNothing from the featureReact, routing, transport
index.tsRoute modules for re-exportInternal implementation on behalf of routes

Tip

Convention, not enforcement

An explicit entry point is a maintained convention. It makes the intended consumer path obvious, but it does not technically stop someone from deep-importing a file. Keep App.tsx on the public path and review new imports as the feature changes.

Avoid speculative abstractions

Keep unproven reuse local

Do not move code merely because it might be useful later. A helper with one demonstrated consumer remains where that consumer owns it. Move feature code only when multiple real consumers require the same behavior; then evaluate the smallest useful shared location.

Decide from evidence

formatAvailability is called only by ProductViews.tsx. What is the best location now?

Apply the public-boundary rules

Complete the feature boundary

Complete this boundary audit: expose the two routes, repair the route’s internal import, and keep a one-consumer helper local.

typescript
// src/features/products/index.ts
export { ProductListRoute } from '';
export { ProductDetailRoute } from '';

// src/features/products/ProductListRoute.tsx
import { ProductFilter } from '';

// Audit note: formatAvailability is used only by ProductViews.tsx.
// Keep the helper in , not in a shared module.

Step 8 of 8

Verify the Refactor Without Changing Behavior

Finish and audit the local refactor, repeat the baseline checks, and explain the resulting responsibility boundaries.

Audit the completed structure

Confirm the final local inventory

Finish the migration deliberately: the application shell imports the two routes from the feature entry point, and modules inside src/features/products/ import sibling files directly. Keep src/App.tsx as the application shell and keep product-specific implementation in the feature folder.

Delete src/ProductRoutes.tsx only after App.tsx imports from ./features/products, the app runs, and no remaining caller imports the obsolete starter module. Moving files is complete only when the final import paths work locally.

Audit the final import direction

A dependency diagram showing the application shell importing a products feature entry point, which exposes two route components that use local views, rules, data access, and types.

Import direction: App.tsx → features/products/index.ts → ProductListRoute.tsx or ProductDetailRoute.tsx. Routes import sibling modules directly. Here, A → B means “A imports B.”

Final file inventory

The completed product feature has a small, inspectable local surface. The fixture remains a read-only local file; it is not a writable API.

Final Trail Shop inventory after the refactor
LocationResponsibility
src/main.tsxMounts the application
src/App.tsxApplication shell and route registration
src/features/products/ProductListRoute.tsxList route composition
src/features/products/ProductDetailRoute.tsxDetail route composition
src/features/products/ProductViews.tsxPresentation
src/features/products/productTypes.tsShared feature contracts
src/features/products/productRules.tsDomain logic
src/features/products/productsApi.tsData access and validation
src/features/products/index.tsPublic feature boundary
public/products.jsonRead-only fixture
src/ProductRoutes.tsxRemoved after migration

Compare behavior, not just files

Confirm the public import path

The shell consumes only the feature entry point.

typescript
// src/features/products/index.ts
export { ProductListRoute } from './ProductListRoute';
export { ProductDetailRoute } from './ProductDetailRoute';

// src/App.tsx
import {
  ProductDetailRoute,
  ProductListRoute
} from './features/products';

Repeat the observable behavior checks

Run the same local application and repeat the baseline journey. Start at /products?source=practice. Select In stock only, then activate the Trail Mug link from that filtered list. Verify the resulting detail URL before using the browser controls.

A successful type check and an import audit are useful evidence for contracts and boundaries. They do not demonstrate that URLs, focus, native links, or browser history still behave correctly.

Before/After browser checklist

Record what your own browser does in the After column. Use the same fixture and sequence as the Before check.

Compare the same Trail Shop behavior before declaring the refactor complete.
CheckBeforeAfter
Open /products?source=practiceThree products; Products heading and Availability labelRecord observation
Select In stock onlyp1 and p3 only; source=practice remains; filter retains focusRecord observation
Activate Trail Mug from filtered listURL is /products/p1?source=practice&availability=in-stock; Trail Mug details and In stock appearRecord observation
Native link behaviorKeyboard activation works; product link can open in another tabRecord observation
Browser historyBack restores filtered list; Forward restores detail pageRecord observation
Open /products/p1Matching product details appearRecord observation
Open /products/p9Well-formed missing identifier shows not-found behaviorRecord observation
Open /products/nopeMalformed identifier shows invalid-identifier behaviorRecord observation
Page navigationEstablished document title and heading-focus behavior occursRecord observation

Tip

Verification reminders

Use your browser observations together with the import audit for the final review.

What successful verification combines

Keep this checklist when you make future structural changes.

Structure checks and browser checks provide different evidence.
EvidenceWhat it supports
Type checkingTyped contracts and compatible imports
Import inspectionResponsibility boundaries and intended dependency direction
Browser checklistObservable URLs, rendering, focus, links, and history

Repair one boundary violation

Repair a misplaced presentation dependency

An import audit finds that ProductViews.tsx imports its Product type from the transport module. Complete the direct sibling import that repairs this dependency without changing the established ProductDetails markup.

tsx
import type { Product } from ;

type ProductDetailsProps = {
  product: Product;
};

export function ProductDetails({ product }: ProductDetailsProps) {
  return (
    <section>
      <h2>{product.name}</h2>
      <p>{product.inStock ? 'In stock' : 'Out of stock'}</p>
    </section>
  );
}

Report and explain the refactor

Document your final verification

After completing the import repair and running the local checks, write a concise Before/After verification report.

Report the three-product list, the two-product in-stock result with source=practice, and the URL after activating Trail Mug from that filtered list. Include what you observed for /products/p1, /products/p9, and /products/nope; keyboard activation and opening a product link in another tab; Back and Forward; filter-control focus; and established page title and heading-focus behavior.

Then identify, by final module name, who owns URL interpretation, rendering, product selection, and validated data access. State how type checking and import inspection helped, and why they did not establish behavioral equivalence alone.

Write at least 350 characters (0/350).

Tutorial completed

Congratulations! You completed: Organize React Features by Responsibility

You verified the refactor through browser evidence and a responsibility-boundary audit.

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate