JavaScript and React course Β· Module 15: Patterns and Architecture

Final Project - Space station architecture

8 min read
In this lesson5

You already know more than a dozen patterns, but you practiced each one separately, like modules tested one by one in a hangar. An orbital station only works once its modules are connected. Time to summarize everything we have learned in this module! Your task is to design and build a Mission Control Dashboard, a space mission control panel that uses all the patterns we have covered.

Project requirements

Your project should use:

  1. Composition: multi-slot layout (header, sidebar, main)
  2. Container/Presentational: separation of logic from UI
  3. Custom hooks: extracted logic (e.g. useMission, useCrewFilter)
  4. Compound Components: tab system or accordion
  5. Atomic Design: component organization into atoms, molecules, organisms

The project for your mentor adds at least three custom hooks of your own: useToggle, useFetch and useFilter.

Project architecture

Before you write the first line, plan the folders. The structure below arranges components by Atomic Design levels and keeps the logic separately in hooks:

1MissionDashboard/
2  atoms/
3    StatusBadge     - displays mission status (active/completed/failed)
4    ProgressBar     - mission progress bar
5    IconButton      - button with icon
6  molecules/
7    MissionCard     - mission card (StatusBadge + title + ProgressBar)
8    CrewMember      - crew member (avatar + name + role)
9  organisms/
10    MissionList     - mission list with filtering
11    CrewPanel       - crew panel with search
12    Tabs/Tab/Panel  - compound component with tabs
13  hooks/
14    useMission      - mission management logic
15    useFilter       - filtering logic
16    useToggle       - state toggle

Atoms do not know about data, molecules combine atoms, and only organisms reach for hooks. If an atom imports a data hook, something went wrong. React does not impose a folder structure: in larger applications code is often grouped by feature (features/missions, features/crew), while Atomic Design is kept for the shared component library.

Key patterns in the project

Custom Hook - useMission

The heart of the panel is a hook that holds the mission list and the current filter, and exposes the filtered data plus an action for changing a status:

1function useMission() {
2  const [missions, setMissions] = useState(initialMissions);
3  const [filter, setFilter] = useState('all');
4
5  const filteredMissions = useMemo(() => {
6    if (filter === 'all') return missions;
7    return missions.filter(m => m.status === filter);
8  }, [missions, filter]);
9
10  const updateStatus = (id, status) => {
11    setMissions(prev =>
12      prev.map(m => m.id === id ? { ...m, status } : m)
13    );
14  };
15
16  return { missions: filteredMissions, filter, setFilter, updateStatus };
17}

useMemo recalculates the list only when the missions or the filter change. updateStatus creates a new array with map and does not modify the original. In the practice task this hook is called useMissionData, but the logic is the same.

Compound Components - Tabs

Compound components are a family of components that work together and share hidden state, like <select> and <option> in HTML. This is how the tabs are used in the panel:

1function MissionControl() {
2  return (
3    <Tabs defaultTab={0}>
4      <Tabs.TabList>
5        <Tabs.Tab index={0}>Missions</Tabs.Tab>
6        <Tabs.Tab index={1}>Crew</Tabs.Tab>
7        <Tabs.Tab index={2}>Systems</Tabs.Tab>
8      </Tabs.TabList>
9
10      <Tabs.Panel index={0}>
11        <MissionList />
12      </Tabs.Panel>
13      <Tabs.Panel index={1}>
14        <CrewPanel />
15      </Tabs.Panel>
16      <Tabs.Panel index={2}>
17        <SystemsOverview />
18      </Tabs.Panel>
19    </Tabs>
20  );
21}

Tabs.Tab and Tabs.Panel do not receive the active tab through props, they read it from a shared context created by Tabs. The skeleton of such a set is short:

1const TabsContext = createContext(null);
2
3function Tabs({ defaultTab = 0, children }) {
4  const [activeTab, setActiveTab] = useState(defaultTab);
5  return (
6    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
7      {children}
8    </TabsContext.Provider>
9  );
10}
11
12Tabs.Tab = function Tab({ index, children }) {
13  const { activeTab, setActiveTab } = useContext(TabsContext);
14  return (
15    <button role="tab" aria-selected={activeTab === index} onClick={() => setActiveTab(index)}>
16      {children}
17    </button>
18  );
19};

The aria-selected attribute only works together with the tab role: thanks to both, a screen reader knows that this is a tab and which one is active. Tabs.TabList is a container with the tablist role, and Tabs.Panel renders its children with the tabpanel role only when activeTab === index. MissionControl does not change, because the context is an internal detail of the set. The tabs in Radix UI are built the same way, and they also link each tab to its panel with the aria-controls attribute.

Composition - Dashboard Layout

Everything is held together by the three-slot layout you know from the first lesson of the module. The header, the side panel and the main area receive ready-made elements from App:

1function DashboardLayout({ header, sidebar, children }) {
2  return (
3    <div className="dashboard">
4      <header className="dash-header">{header}</header>
5      <div className="dash-body">
6        <aside className="dash-sidebar">{sidebar}</aside>
7        <main className="dash-content">{children}</main>
8      </div>
9    </div>
10  );
11}
12
13function App() {
14  return (
15    <DashboardLayout
16      header={<TopBar title="Mission Control" />}
17      sidebar={<StatusPanel />}
18    >
19      <MissionControl />
20    </DashboardLayout>
21  );
22}

DashboardLayout knows nothing about missions or tabs, so the same layout will also serve the crew panel.

Implementation tips

Step 1: Custom hooks

Start by extracting logic into custom hooks. Each hook should have a single responsibility, like this toggle:

1// useToggle - simple state toggle
2function useToggle(initial = false) {
3  const [value, setValue] = useState(initial);
4  const toggle = useCallback(() => setValue(v => !v), []);
5  return [value, toggle];
6}

useToggle returns a pair like useState, and useCallback makes toggle the same function between renders. The second hook filters any list by the text typed in:

1// useFilter - filtering logic (no debounce)
2function useFilter(items, filterFn) {
3  const [query, setQuery] = useState('');
4
5  const filteredItems = useMemo(() => {
6    if (!query) return items;
7    return items.filter(item => filterFn(item, query));
8  }, [items, query, filterFn]);
9
10  return { filteredItems, query, setQuery };
11}

This hook has no debounce: it filters on every keystroke, which is enough for a few dozen missions. For long lists, the built-in useDeferredValue helps keep typing smooth. Define filterFn outside the component, because a new function on every render invalidates useMemo. You will write the third hook, useFetch(url), like useCrew from the Container/Presentational lesson: data, loading and error state plus a useEffect that fetches.

Step 2: Atoms and molecules

Build simple, reusable components. An atom knows nothing about missions, it only knows the status it has to show and the statusLabels map with labels for the crew:

1// Atom - StatusBadge
2const statusLabels = { active: 'Active', completed: 'Completed', failed: 'Failed' };
3
4function StatusBadge({ status }) {
5  const colors = {
6    active: '#00e676',
7    completed: '#64b5f6',
8    failed: '#f44336',
9  };
10  return (
11    <span style={{ background: colors[status], padding: '4px 12px', borderRadius: '20px' }}>
12      {statusLabels[status]}
13    </span>
14  );
15}

The status values stay as codes, because the logic compares them, while the crew sees readable labels. A molecule combines atoms into something that already has meaning: a card for a specific mission with its name, status and progress bar:

1// Molecule - MissionCard (combines atoms)
2function MissionCard({ mission, onStatusChange }) {
3  return (
4    <div className="mission-card">
5      <h3>{mission.name}</h3>
6      <StatusBadge status={mission.status} />
7      <ProgressBar value={mission.progress} />
8    </div>
9  );
10}

MissionCard accepts onStatusChange but does not use it yet. You will wire it up in the project, for example to a button that marks a mission as completed. ProgressBar is the second atom, which you already know from the Atomic Design lesson.

Step 3: Organisms with data

Combine molecules into larger structures. The organism is the first to reach for the data hook and hands the data out to the cards:

1// MissionList - organism with filtering
2function MissionList() {
3  const { missions, filter, setFilter, updateStatus } = useMission();
4
5  return (
6    <div>
7      <FilterBar value={filter} onChange={setFilter} />
8      {missions.map(m => (
9        <MissionCard key={m.id} mission={m} onStatusChange={updateStatus} />
10      ))}
11    </div>
12  );
13}

MissionList plays the role of a container component here: it takes data from useMission and passes it down. FilterBar is another molecule to write.

Step 4: Assemble into a template

Finally, combine everything into a layout with composition. In Atomic Design this level is the template: DashboardLayout with slots into which you put organisms. Filled with real data, it becomes a page, the fifth and last level.

Evaluation criteria

  1. Composition: layout with at least 3 slots (header, sidebar, main)
  2. Container/Presentational: separation of logic from view in at least 2 components
  3. Custom hooks: at least 3 custom hooks with reusable logic (useToggle, useFetch, useFilter)
  4. Compound Components: tab system or accordion with shared state
  5. Atomic Design: visible organization into atoms, molecules, organisms

My advice: build from the bottom up and run the application after every step. First the hooks with their data printed to the console, then the atoms, and finally the whole panel. Good luck, pilot! Show that you can design React architecture like a true space engineer.

Remember: an orbital station is not one big module, but many small, well-connected ones.

Code for this lesson: App.jsx
1import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
2
3// =============================================
4// CUSTOM HOOKS
5// =============================================
6function useToggle(initial = false) {
7  const [value, setValue] = useState(initial);
8  const toggle = useCallback(() => setValue(p => !p), []);
9  return [value, toggle];
10}
11
12function useMission() {
13  const [missions, setMissions] = useState([
14    { id: 1, name: 'Explore Alpha Centauri', status: 'active', progress: 65 },
15    { id: 2, name: 'Asteroid mining operation', status: 'active', progress: 30 },
16    { id: 3, name: 'Rescue team Bravo', status: 'completed', progress: 100 },
17    { id: 4, name: 'Deep space scan', status: 'failed', progress: 12 },
18    { id: 5, name: 'Station resupply', status: 'active', progress: 80 },
19  ]);
20  const [filter, setFilter] = useState('all');
21
22  const filteredMissions = useMemo(() => {
23    if (filter === 'all') return missions;
24    return missions.filter(m => m.status === filter);
25  }, [missions, filter]);
26
27  const updateStatus = useCallback((id, status) => {
28    setMissions(prev => prev.map(m =>
29      m.id === id ? { ...m, status, progress: status === 'completed' ? 100 : m.progress } : m
30    ));
31  }, []);
32
33  return { missions: filteredMissions, allMissions: missions, filter, setFilter, updateStatus };
34}
35
36// =============================================
37// COMPOUND COMPONENT: Tabs
38// =============================================
39const TabsCtx = createContext();
40
41function Tabs({ children, defaultTab = 0 }) {
42  const [active, setActive] = useState(defaultTab);
43  return (
44    <TabsCtx.Provider value={{ active, setActive }}>
45      <div className="tabs">{children}</div>
46    </TabsCtx.Provider>
47  );
48}
49Tabs.TabList = function TabList({ children }) {
50  return <div className="tab-list" role="tablist">{children}</div>;
51};
52Tabs.Tab = function Tab({ index, children }) {
53  const { active, setActive } = useContext(TabsCtx);
54  const selected = active === index;
55  return (
56    <button role="tab" aria-selected={selected} className={`tab ${selected ? 'active' : ''}`} onClick={() => setActive(index)}>
57      {children}
58    </button>
59  );
60};
61Tabs.Panel = function TabPanel({ index, children }) {
62  const { active } = useContext(TabsCtx);
63  return active === index ? <div className="tab-panel" role="tabpanel">{children}</div> : null;
64};
65
66// =============================================
67// ATOMS
68// =============================================
69// Status values stay codes in the data, the crew sees readable labels
70const statusLabels = { active: 'Active', completed: 'Completed', failed: 'Failed' };
71const filterLabels = { all: 'All', active: 'Active', completed: 'Completed', failed: 'Failed' };
72
73function StatusBadge({ status }) {
74  const colors = { active: 'green', completed: 'blue', failed: 'red' };
75  return <span className={`badge badge-${colors[status] || 'gray'}`}>{statusLabels[status] || status}</span>;
76}
77
78function ProgressBar({ value, label }) {
79  const color = value >= 80 ? '#4caf50' : value >= 40 ? '#ff9800' : '#f44336';
80  return (
81    <div className="progress-bar" role="progressbar" aria-label={label} aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
82      <div className="progress-fill" style={{ width: `${value}%`, background: color }} />
83    </div>
84  );
85}
86
87// =============================================
88// MOLECULES
89// =============================================
90function MissionCard({ mission, onComplete, onCancel }) {
91  return (
92    <div className="mission-card">
93      <div className="mc-header">
94        <h4>{mission.name}</h4>
95        <StatusBadge status={mission.status} />
96      </div>
97      <ProgressBar value={mission.progress} label={mission.name} />
98      {mission.status === 'active' && (
99        <div className="mc-actions">
100          <button className="btn-sm btn-success" onClick={() => onComplete(mission.id)}>
101            Complete
102          </button>
103          <button className="btn-sm btn-danger" onClick={() => onCancel(mission.id)}>
104            Cancel
105          </button>
106        </div>
107      )}
108    </div>
109  );
110}
111
112function CrewMember({ name, role }) {
113  return (
114    <div className="crew-member">
115      <div className="cm-avatar">{name[0]}</div>
116      <div>
117        <div className="cm-name">{name}</div>
118        <div className="cm-role">{role}</div>
119      </div>
120    </div>
121  );
122}
123
124// =============================================
125// ORGANISMS
126// =============================================
127function MissionList({ missions, onComplete, onCancel }) {
128  return (
129    <div className="mission-list">
130      {missions.length === 0 && <p className="empty-msg">No missions match filter</p>}
131      {missions.map(m => (
132        <MissionCard key={m.id} mission={m} onComplete={onComplete} onCancel={onCancel} />
133      ))}
134    </div>
135  );
136}
137
138function CrewPanel() {
139  const crew = [
140    { name: 'Commander Nova', role: 'Captain' },
141    { name: 'Dr. Stellar', role: 'Medical Officer' },
142    { name: 'Astro', role: 'Engineer' },
143    { name: 'Ra', role: 'Navigator' },
144  ];
145  return (
146    <div className="crew-panel">
147      {crew.map(c => <CrewMember key={c.name} name={c.name} role={c.role} />)}
148    </div>
149  );
150}
151
152function SystemsOverview() {
153  const systems = [
154    { name: 'Engines', value: 95 },
155    { name: 'Shields', value: 72 },
156    { name: 'Life Support', value: 100 },
157    { name: 'Communications', value: 88 },
158  ];
159  return (
160    <div className="systems-overview">
161      {systems.map(s => (
162        <div key={s.name} className="sys-row">
163          <span>{s.name}</span>
164          <ProgressBar value={s.value} label={s.name} />
165          <span className="sys-pct">{s.value}%</span>
166        </div>
167      ))}
168    </div>
169  );
170}
171
172// =============================================
173// TEMPLATE (Composition)
174// =============================================
175function DashboardLayout({ header, sidebar, children }) {
176  return (
177    <div className="dashboard-layout">
178      <header className="dl-header">{header}</header>
179      <div className="dl-body">
180        {sidebar && <aside className="dl-sidebar">{sidebar}</aside>}
181        <main className="dl-main">{children}</main>
182      </div>
183    </div>
184  );
185}
186
187// =============================================
188// PAGE
189// =============================================
190export default function App() {
191  const { missions, allMissions, filter, setFilter, updateStatus } = useMission();
192  const [showFilters, toggleFilters] = useToggle(true);
193
194  const filters = ['all', 'active', 'completed', 'failed'];
195
196  // Stats come from the full list, so the filter does not change the totals in the header
197  const stats = useMemo(() => ({
198    total: allMissions.length,
199    active: allMissions.filter(m => m.status === 'active').length,
200    done: allMissions.filter(m => m.status === 'completed').length,
201  }), [allMissions]);
202
203  return (
204    <DashboardLayout
205      header={
206        <div className="header-bar">
207          <h1>Mission Control Dashboard</h1>
208          <div className="header-stats">
209            <span>Total: {stats.total}</span>
210            <span>Active: {stats.active}</span>
211            <span>Done: {stats.done}</span>
212            <button className="toggle-btn" onClick={toggleFilters}>
213              {showFilters ? 'Hide filters' : 'Show filters'}
214            </button>
215          </div>
216        </div>
217      }
218      sidebar={showFilters && (
219        <div>
220          <h3 className="sidebar-title">Mission filter</h3>
221          <div className="filter-group">
222            {filters.map(f => (
223              <button
224                key={f}
225                className={`filter-btn ${filter === f ? 'active' : ''}`}
226                aria-pressed={filter === f}
227                onClick={() => setFilter(f)}
228              >
229                {filterLabels[f]}
230              </button>
231            ))}
232          </div>
233        </div>
234      )}
235    >
236      <Tabs defaultTab={0}>
237        <Tabs.TabList>
238          <Tabs.Tab index={0}>Missions</Tabs.Tab>
239          <Tabs.Tab index={1}>Crew</Tabs.Tab>
240          <Tabs.Tab index={2}>Systems</Tabs.Tab>
241        </Tabs.TabList>
242        <Tabs.Panel index={0}>
243          <MissionList
244            missions={missions}
245            onComplete={(id) => updateStatus(id, 'completed')}
246            onCancel={(id) => updateStatus(id, 'failed')}
247          />
248        </Tabs.Panel>
249        <Tabs.Panel index={1}>
250          <CrewPanel />
251        </Tabs.Panel>
252        <Tabs.Panel index={2}>
253          <SystemsOverview />
254        </Tabs.Panel>
255      </Tabs>
256    </DashboardLayout>
257  );
258}

Spotted a mistake in this lesson?

Hands-on tasks in the game

  • Code editor

    Combine the patterns of the module in one dashboard. The useMissionData hook: ___BLANK1___ is the list of missions whose status equals the filter (when the filter is 'all', the hook returns every mission). The useToggle hook: ___BLANK2___ is an updater function that flips the previous value (toggle is wrapped in useCallback with an empty array, so it cannot read value from the first render). The Tabs compound component: ___BLANK3___ is the object with activeTab and setActiveTab that Tabs provides through TabsContext, and ___BLANK4___ sets aria-selected to true only for the active tab. The atoms, the molecule, the organism and DashboardLayout with its slots are ready.

Useful articles