JavaScript and React course Β· Module 15: Patterns and Architecture
Final Project - Space station architecture
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:
- Composition: multi-slot layout (
header,sidebar,main) - Container/Presentational: separation of logic from UI
- Custom hooks: extracted logic (e.g.
useMission,useCrewFilter) - Compound Components: tab system or accordion
- 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 toggleAtoms 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
- Composition: layout with at least 3 slots (header, sidebar, main)
- Container/Presentational: separation of logic from view in at least 2 components
- Custom hooks: at least 3 custom hooks with reusable logic (
useToggle,useFetch,useFilter) - Compound Components: tab system or accordion with shared state
- 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.