TaskShelf is a small task application built as a reference architecture for React, TypeScript, Vite, and Material UI.
The application is intentionally simple. Its purpose is to demonstrate how to organize a frontend project so that it remains easy to understand, test, and extend as it grows.
Use this repository to:
- learn the responsibilities of each layer in a React application;
- see a feature-based folder structure in practice;
- build reusable Material UI components with typed styles;
- isolate asynchronous data access behind services and hooks;
- test components through user-visible behavior;
- develop components independently with Storybook;
- start a new project from an opinionated but lightweight foundation.
| Area | Technology |
|---|---|
| UI | React 19 |
| Language | TypeScript |
| Build tool | Vite |
| Component library | Material UI |
| Routing | React Router |
| Unit and component tests | Vitest + Testing Library |
| Component development | Storybook |
| Code quality | ESLint + Prettier |
| Git hooks | Husky + lint-staged |
The example application contains:
- a shared application layout;
- Home and Tasks routes;
- task creation and status filtering;
- a simulated asynchronous API;
- loading, empty, and populated UI states;
- reusable UI components;
- component stories and focused tests.
Task data is stored only in memory. Refreshing the page restores the initial sample tasks. The service boundary is deliberately shaped so that a real API can replace the mock implementation without rewriting the UI.
- Node.js
20.19+or22.12+ - npm, included with Node.js
npm install
npm run devThe application runs at http://localhost:5190.
npm run lint
npm run format:check
npm test
npm run build| Command | Purpose |
|---|---|
npm run dev |
Start the Vite development server on port 5190 |
npm run build |
Type-check the project and create a production build |
npm run preview |
Serve the production build locally |
npm run lint |
Check the codebase with ESLint |
npm run format |
Format supported files with Prettier |
npm run format:check |
Check formatting without changing files |
npm test |
Run the test suite once |
npm run test:watch |
Run tests in watch mode |
npm run test:coverage |
Run tests and generate a coverage report |
npm run test:ui |
Open the Vitest UI |
npm run storybook |
Start Storybook on port 6006 |
npm run build-storybook |
Create a static Storybook build |
TaskShelf/
├── .husky/ # Git hooks
├── .storybook/ # Storybook configuration and decorators
├── public/ # Files copied directly to the build output
├── src/
│ ├── app/
│ │ ├── providers/ # Application-wide provider composition
│ │ └── App.tsx # Application root
│ ├── assets/ # Imported images and other static assets
│ ├── components/
│ │ ├── layout/ # Shared page layouts
│ │ └── ui/ # Reusable, feature-independent UI
│ ├── features/
│ │ ├── home/
│ │ └── tasks/
│ │ ├── components/ # UI used by the tasks feature
│ │ ├── hooks/ # Feature state and orchestration
│ │ ├── services/ # Task data access
│ │ ├── types/ # Task domain types
│ │ └── views/ # Route-level feature composition
│ ├── hooks/ # Reusable, feature-independent hooks
│ ├── routes/ # Route definitions and path constants
│ ├── services/ # Application-wide service infrastructure
│ ├── test/ # Shared test setup and render helpers
│ ├── theme/ # MUI palette, typography, and overrides
│ ├── types/ # Shared TypeScript types
│ ├── utils/ # Pure reusable functions
│ └── main.tsx # Browser entry point
├── eslint.config.js
├── vite.config.ts
└── vitest.config.ts
The project follows a feature-first architecture. Code that belongs to one business capability stays inside that feature, while genuinely reusable code lives in a shared top-level folder.
Use the following dependency direction as a rule of thumb:
main
└── app
├── routes
│ ├── layouts
│ └── feature views
│ ├── feature components
│ ├── feature hooks
│ ├── feature services
│ └── feature types
└── shared infrastructure
├── theme
├── UI components
├── hooks
├── services
└── utilities
Shared code must not import from a feature. A feature may import shared code, but it should not reach into another feature's internal folders. If multiple features need the same behavior, move that behavior to the appropriate shared folder.
A view is the route-level composition point for a feature.
TasksView obtains state and actions from hooks, then passes them into
components. It does not contain data-access details or low-level presentation
logic.
Hooks own stateful behavior and coordinate services.
useTasksloads tasks, tracks request state, and exposes task actions.useTaskStatusFilterowns the selected filter and derives visible tasks.useToggleis feature-independent, so it lives insrc/hooks.
This keeps components focused on rendering and interaction.
Services define the boundary between the application and its data source.
taskService.ts exposes domain-oriented functions such as getTasks,
createTask, and updateTaskStatus. It currently calls a small async mock
client, but the same public functions can later call fetch, Axios, or an SDK.
Components do not call the API directly. This prevents transport details from spreading across the UI.
Components are grouped by their reuse scope:
components/ui: generic components that know nothing about a feature;components/layout: shared structural components;features/*/components: components that understand one feature's domain.
Props describe the component contract. Data fetching and route composition stay outside presentational components whenever practical.
Domain types stay near their feature. Types move to src/types only when they
are truly shared across unrelated parts of the application.
Creating a task demonstrates the intended flow:
User submits TaskForm
→ TaskToolbar forwards the callback
→ TasksView connects UI to useTasks
→ useTasks calls createTask
→ taskService uses the API client
→ useTasks updates React state
→ the component tree renders the new task
Each layer has one clear reason to change:
- form markup changes in
TaskForm; - feature behavior changes in
useTasks; - backend communication changes in
taskService; - route-level composition changes in
TasksView.
Global providers are composed in src/app/providers/AppProviders.tsx.
The current provider tree contains:
- MUI's
ThemeProvider; - MUI's
CssBaseline; - React Router's
BrowserRouter.
Add future application-wide providers here, such as authentication, server
state, localization, or error monitoring. Keeping provider setup in one place
keeps main.tsx and App.tsx small.
Test code uses a parallel TestProviders component with MemoryRouter. This
gives tests the same theme and routing context without depending on browser
history.
Routes are declared in src/routes/AppRoutes.tsx, while URL values are kept in
src/routes/RoutePaths.ts.
Central path constants:
- avoid duplicated URL strings;
- make navigation and route definitions agree;
- make future path changes safer.
MainLayout uses React Router's Outlet to render nested route content. Unknown
URLs redirect to the home page.
- Create a view in the relevant feature.
- Export it from the feature's
views/index.ts. - Add its path to
ROUTE_PATHS. - Add a
RouteinAppRoutes. - Add navigation only if the route should be directly discoverable.
The MUI theme is split by concern:
src/theme/
├── palette.ts
├── typography.ts
├── components.ts
└── theme.ts
theme.ts only composes the individual theme sections. This makes design-token
changes easy to locate and prevents one large theme file from becoming a
catch-all.
Global component defaults belong in theme/components.ts. For example, all
buttons share elevation, border-radius, font-weight, and text-transform rules.
Component-specific styles live beside their component:
TaskCard/
├── TaskCard.tsx
├── TaskCard.styles.ts
├── TaskCard.test.tsx
├── TaskCard.stories.tsx
└── index.ts
Style objects use satisfies Record<string, SxProps<Theme>>. This preserves
useful inference while checking that every style is valid for MUI's sx prop.
- Prefer theme values and semantic tokens over isolated hard-coded values.
- Put reusable global defaults in the theme.
- Keep component-only
sxobjects in a colocated.styles.tsfile. - Use MUI's responsive object syntax for breakpoint changes.
- Keep JSX readable by moving substantial style objects out of the component.
The alias @/ maps to src/:
import { PageContainer } from "@/components/ui/PageContainer";The alias is configured in both Vite and Vitest, and its TypeScript path is
declared in tsconfig.app.json.
Folders expose a small public API through index.ts files. Import from that
public entry point instead of reaching into a component's internal files. Avoid
large application-wide barrel files: local barrels keep ownership clear and
reduce accidental coupling.
Use import type when an import is needed only by TypeScript. The project uses
verbatimModuleSyntax, so the distinction is explicit and enforced.
Tests use Vitest, jsdom, Testing Library, user-event, and jest-dom matchers.
The shared renderWithProviders helper wraps components with the MUI theme and
a memory router:
renderWithProviders(<TaskForm onCreateTask={onCreateTask} />);This removes repeated test setup and keeps tests close to the way components run in the application.
- Test observable behavior rather than implementation details.
- Query by accessible role, label, or text whenever possible.
- Use
userEventfor realistic interactions. - Test pure utilities and hooks independently when that gives clearer coverage.
- Keep tests beside the code they protect.
- Add shared providers to
TestProviderswhen application context changes.
Run tests once:
npm testRun tests while developing:
npm run test:watchStories are colocated with components and views. They document meaningful UI states without requiring navigation through the application.
Examples include:
- default and submitting forms;
- empty and populated task lists;
- task status variants;
- complete route views;
- shared layout and UI primitives.
The Storybook preview applies the real MUI theme and CssBaseline, so stories
render in the same visual environment as the application. Accessibility,
documentation, and Vitest integrations are already configured.
npm run storybookStory titles follow the ownership structure, for example:
Features/Tasks/Components/TaskForm
Components/UI/PageTitle
Components/Layout/MainLayout
When adding a component, create stories for the states a developer or designer needs to inspect, not merely one story to satisfy a convention.
The repository includes:
- TypeScript checks during
npm run build; - ESLint rules for TypeScript, React hooks, React Refresh, and Storybook;
- Prettier for consistent formatting;
- EditorConfig for editor-independent whitespace rules;
- Husky and lint-staged for checking staged files before a commit.
The pre-commit hook formats staged files and applies ESLint fixes to TypeScript files. It is a fast guardrail, not a replacement for running the full validation commands before opening a pull request.
Start with this structure and add folders only when they have a real purpose:
src/features/example/
├── components/
├── hooks/
├── services/
├── types/
└── views/
Recommended process:
- Define the domain types.
- Define service functions around user-facing operations.
- Create hooks that coordinate service calls and local state.
- Build small prop-driven components.
- Compose those pieces in a view.
- Register the route.
- Add tests for behavior and stories for visual states.
Do not create every possible folder preemptively. A feature with one simple
view may need only views/. Let the structure grow with actual responsibilities.
Place a component in src/components/ui only when it is independent of a
business feature.
src/components/ui/Example/
├── Example.tsx
├── Example.styles.ts
├── Example.test.tsx
├── Example.stories.tsx
└── index.ts
Before promoting a component to shared UI, check that:
- its props use generic UI concepts rather than feature types;
- it does not import from
src/features; - at least one realistic reuse case exists;
- its public API is smaller than the implementation it replaces.
Not every component needs a separate styles file, test, and story. Use them when they add clarity or protect meaningful behavior.
The UI already depends on taskService, not on the mock API client. To connect
a backend:
- Configure the API base URL through Vite environment variables.
- Replace the mock calls inside
taskService.tswith real requests. - Map transport responses to the feature's domain types.
- Add explicit error state to
useTasks. - Render retry or error feedback in the feature UI.
- Mock the service boundary in focused component tests.
Keep request and response details inside services. Avoid making components understand HTTP status codes, endpoint paths, or backend-specific payloads.
Client-exposed Vite environment variables must begin with VITE_, for example:
VITE_API_URL=https://api.example.comDo not place secrets in frontend environment variables. Values bundled into a browser application are visible to its users.
When starting a new application:
- Replace the name and metadata in
package.json. - Update the title, description, favicon, and branding assets.
- Replace the example Home and Tasks features with your domain.
- Customize the palette, typography, and component defaults.
- Keep only shared components that remain useful.
- Choose a real data strategy and implement it behind feature services.
- Add environment files and deployment configuration as needed.
- Update this README so it describes the resulting application truthfully.
Preserve the architectural principles, not every file:
- organize business code by feature;
- keep dependencies pointing toward shared infrastructure;
- separate rendering, state orchestration, and data access;
- colocate related implementation, tests, stories, and styles;
- extract shared code only after its reuse is clear;
- keep the application root and provider setup small.
This repository is an educational baseline, not a complete production policy. Before shipping a real application, consider:
- error boundaries and user-facing request errors;
- authentication and authorization;
- server-state caching and invalidation;
- environment validation;
- observability and error reporting;
- end-to-end tests for critical journeys;
- automated CI checks;
- deployment and rollback strategy;
- accessibility review;
- security headers and dependency auditing;
- internationalization, if required by the product.
The correct additions depend on the product. Add them deliberately instead of loading the starter with infrastructure that may never be used.