Day-14 Assignment
Folder Structure
src/
└── budget-tracker/
├── BudgetTracker.tsx Main parent component
├── PortfolioSummary.tsx 1.1 - total + average
├── AssetEditor.tsx 1.2 - class component
└── ExchangeRateDisplay.tsx 1.3 - currency rates viewer
Section 1: TSX & Typed Components
Build a BudgetTracker component that:
Tracks income and expenses in different currencies.
Shows net balance in selected currency.
Uses useReducer for state management.
Implements type-safe props for currency conversion rates.
src/components/BudgetTracker.tsx:
// BudgetTracker.tsx
import React, { useReducer } from 'react';
// --- TYPES ---
type Currency = 'USD' | 'EUR' | 'INR';
interface Entry {
id: number;
description: string;
amount: number;
currency: Currency;
type: 'income' | 'expense';
}
type BudgetState = {
entries: Entry[];
selectedCurrency: Currency;
};
type ConversionRates = {
[key in Currency]: number;
};
type Action =
| { type: 'ADD_ENTRY'; payload: Entry }
| { type: 'SET_CURRENCY'; payload: Currency };
// --- REDUCER ---
const reducer = (state: BudgetState, action: Action): BudgetState => {
switch (action.type) {
case 'ADD_ENTRY':
return { ...state, entries: [...state.entries, action.payload] };
case 'SET_CURRENCY':
return { ...state, selectedCurrency: action.payload };
default:
return state;
}
};
// --- COMPONENT ---
interface BudgetTrackerProps {
rates: ConversionRates;
}
const BudgetTracker: React.FC<BudgetTrackerProps> = ({ rates }) => {
const [state, dispatch] = useReducer(reducer, {
entries: [],
selectedCurrency: 'USD'
});
const handleAddEntry = () => {
const newEntry: Entry = {
id: Date.now(),
description: 'Sample Entry',
amount: 100,
currency: 'INR',
type: 'income'
};
dispatch({ type: 'ADD_ENTRY', payload: newEntry });
};
const convertedBalance = state.entries.reduce((total, entry) => {
const rate = rates[entry.currency] / rates[state.selectedCurrency];
const signedAmount = entry.type === 'income' ? entry.amount : -entry.amount;
return total + signedAmount * rate;
}, 0);
return (
<div>
<h2>Budget Tracker</h2>
<select
value={state.selectedCurrency}
onChange={(e) => dispatch({ type: 'SET_CURRENCY', payload: e.target.value as
Currency })}
>
{Object.keys(rates).map((cur) => (
<option key={cur}>{cur}</option>
))}
</select>
<button onClick={handleAddEntry}>Add Entry</button>
<h3>Net Balance: {convertedBalance.toFixed(2)} {state.selectedCurrency}</h3>
</div>
);
};
export default BudgetTracker;
src/App.tsx:
import React from 'react';
import BudgetTracker from './components/BudgetTracker';
const App = () => {
return (
<BudgetTracker rates={{ USD: 1, INR: 83, EUR: 0.93 }} />
);
};
export default App;
Output:
Section 2:
1. Create a PortfolioSummary functional component that:
Receives a typed array of assets ( Asset[] ) as props.
Renders the total value and average percentage change
A:
src/components/PortfolioSummary.tsx:
import React from 'react';
type Asset = {
name: string;
symbol: string;
value: number;
changePercent: number;
};
interface PortfolioSummaryProps {
assets: Asset[];
}
const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({ assets }) => {
const totalValue = assets.reduce((sum, asset) => sum + asset.value, 0);
const averageChange = assets.length
? assets.reduce((sum, asset) => sum + asset.changePercent, 0) / assets.length
: 0;
return (
<div>
<h2>Portfolio Summary</h2>
<ul>
{assets.map((a, idx) => (
<li key={idx}>
{a.name} ({a.symbol}) - ${a.value.toFixed(2)} | {a.changePercent.toFixed(2)}%
</li>
))}
</ul>
<p><strong>Total Value:</strong> ${totalValue.toFixed(2)}</p>
<p><strong>Average Change:</strong> {averageChange.toFixed(2)}%</p>
</div>
);
};
export default PortfolioSummary;
src/App.tsx:
import React from 'react';
import PortfolioSummary from './components/PortfolioSummary';
const sampleAssets = [
{ name: 'Apple', symbol: 'AAPL', value: 1000, changePercent: 5 },
{ name: 'Tesla', symbol: 'TSLA', value: 800, changePercent: -2 },
{ name: 'Google', symbol: 'GOOGL', value: 1200, changePercent: 3.5 }
];
const App = () => (
<div>
<PortfolioSummary assets={sampleAssets} />
</div>
);
export default App;
Output:
2. Create an AssetEditor class component that:
Has typed state for name, symbol, value, and change.
Accepts a callback prop onUpdate (typed) to update an asset.
Resets the form after submission
Routing in React: Type-Safe Route Parameters with React Router & TypeScript
A: npm install react-router-dom
npm install --save-dev @types/react-router-dom
src/components/AssetEditor.tsx:
// src/AssetEditor.tsx
import React, { ChangeEvent } from 'react';
type Asset = {
name: string;
symbol: string;
value: number;
changePercent: number;
};
interface AssetEditorProps {
onUpdate: (asset: Asset) => void;
existingAsset?: Asset;
}
interface AssetEditorState {
name: string;
symbol: string;
value: string;
changePercent: string;
}
class AssetEditor extends React.Component<AssetEditorProps, AssetEditorState> {
constructor(props: AssetEditorProps) {
super(props);
this.state = {
name: props.existingAsset?.name || '',
symbol: props.existingAsset?.symbol || '',
value: props.existingAsset?.value?.toString() || '',
changePercent: props.existingAsset?.changePercent?.toString() || ''
};
}
handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
this.setState({ [name]: value } as Pick<AssetEditorState, keyof AssetEditorState>);
};
handleSubmit = () => {
const { name, symbol, value, changePercent } = this.state;
if (!name || !symbol || isNaN(+value) || isNaN(+changePercent)) {
alert('Please enter valid values for all fields.');
return;
}
const asset: Asset = {
name,
symbol,
value: parseFloat(value),
changePercent: parseFloat(changePercent)
};
this.props.onUpdate(asset); // ⬅ Will trigger navigation from wrapper
this.setState({
name: '',
symbol: '',
value: '',
changePercent: ''
});
};
render() {
return (
<div>
<h3>{this.props.existingAsset ? 'Edit Asset' : 'Add New Asset'}</h3>
<input
type="text"
name="name"
placeholder="Asset Name"
value={this.state.name}
onChange={this.handleChange}
/>
<input
type="text"
name="symbol"
placeholder="Symbol"
value={this.state.symbol}
onChange={this.handleChange}
/>
<input
type="number"
name="value"
placeholder="Value"
value={this.state.value}
onChange={this.handleChange}
/>
<input
type="number"
name="changePercent"
placeholder="Change %"
value={this.state.changePercent}
onChange={this.handleChange}
/>
<button onClick={this.handleSubmit}>
{this.props.existingAsset ? 'Update' : 'Add'}
</button>
</div>
);
}
}
export default AssetEditor;
src/components/AddAsset.tsx:
// src/components/AddAsset.tsx
import React from 'react';
import { useNavigate } from 'react-router-dom';
import AssetEditor from '../AssetEditor';
type Asset = {
name: string;
symbol: string;
value: number;
changePercent: number;
};
interface AddAssetProps {
onUpdate: (asset: Asset) => void;
}
const AddAsset: React.FC<AddAssetProps> = ({ onUpdate }) => {
const navigate = useNavigate();
const handleUpdate = (asset: Asset) => {
onUpdate(asset);
navigate('/'); // ⬅ Navigate to portfolio summary
};
return <AssetEditor onUpdate={handleUpdate} />;
};
export default AddAsset;
src/EditAsset.tsx:
import React from 'react';
import { useParams } from 'react-router-dom';
import AssetEditor from '../AssetEditor';
type Asset = {
name: string;
symbol: string;
value: number;
changePercent: number;
};
interface EditAssetProps {
assets: Asset[];
onUpdate: (asset: Asset) => void;
}
const EditAsset: React.FC<EditAssetProps> = ({ assets, onUpdate }) => {
const { symbol } = useParams<{ symbol: string }>();
const asset = assets.find(a => a.symbol === symbol);
return asset ? (
<AssetEditor existingAsset={asset} onUpdate={onUpdate} />
):(
<p>Asset not found</p>
);
};
export default EditAsset;
src/App.tsx:
import React, { useState } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import PortfolioSummary from './components/PortfolioSummary';
import EditAsset from './components/EditAsset';
import AddAsset from './components/AddAsset';
type Asset = {
name: string;
symbol: string;
value: number;
changePercent: number;
};
const App: React.FC = () => {
const [assets, setAssets] = useState<Asset[]>([]);
const handleUpdate = (asset: Asset) => {
const updatedAssets = assets.some(a => a.symbol === asset.symbol)
? assets.map(a => (a.symbol === asset.symbol ? asset : a))
: [...assets, asset];
setAssets(updatedAssets);
};
return (
<Router>
<Routes>
<Route path="/" element={<PortfolioSummary assets={assets} />} />
<Route path="/add" element={<AddAsset onUpdate={handleUpdate} />} />
<Route path="/edit/:symbol" element={<EditAsset assets={assets}
onUpdate={handleUpdate} />} />
</Routes>
</Router>
);
};
export default App;
Output:
Section 3:
Define a route /doctors/:doctorId/patients/:patientId and a DoctorPatientDetails
component.
Use a typed interface for params and extract them in the component.
Validate that both IDs are present and numeric; display an error if not.
Add a link from a doctor list to a specific doctor/patient page, passing the IDs as
parameters.
State Management in React: Context Providers & Zustand (with TypeScript)
A:
Src/store/useAssetStore.ts
import { create } from 'zustand';
export type Asset = {
name: string;
symbol: string;
value: number;
changePercent: number;
};
interface AssetState {
assets: Asset[];
updateAsset: (newAsset: Asset) => void;
}
export const useAssetStore = create<AssetState>((set) => ({
assets: [
{ name: 'Apple', symbol: 'AAPL', value: 150, changePercent: 1.5 },
{ name: 'Tesla', symbol: 'TSLA', value: 200, changePercent: -0.8 },
{ name: 'Google', symbol: 'GOOGL', value: 100, changePercent: 0.4 },
],
updateAsset: (newAsset) =>
set((state) => {
const updatedAssets = state.assets.some((a) => a.symbol ===
newAsset.symbol)
? state.assets.map((a) =>
a.symbol === newAsset.symbol ? newAsset : a
)
: [...state.assets, newAsset];
return { assets: updatedAssets };
}),
}));
Src/context/DoctorContext.tsx:
import React, { createContext, useContext, useState } from 'react';
interface DoctorContextType {
selectedDoctorId: number | null;
setSelectedDoctorId: (id: number) => void;
}
const DoctorContext = createContext<DoctorContextType | undefined>(undefined);
export const DoctorProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [selectedDoctorId, setSelectedDoctorId] = useState<number | null>(null);
return (
<DoctorContext.Provider value={{ selectedDoctorId, setSelectedDoctorId }}>
{children}
</DoctorContext.Provider>
);
};
export const useDoctorContext = () => {
const context = useContext(DoctorContext);
if (!context) throw new Error("useDoctorContext must be used within DoctorProvider");
return context;
};
Src/components/DoctorList.tsx
import React from 'react';
import { Link } from 'react-router-dom';
const DoctorList: React.FC = () => {
const sampleDoctors = [
{ doctorId: 1, patientId: 101 },
{ doctorId: 2, patientId: 202 }
];
return (
<div>
<h3>Doctors</h3>
<ul>
{sampleDoctors.map(({ doctorId, patientId }) => (
<li key={doctorId}>
<Link to={`/doctors/${doctorId}/patients/${patientId}`}>
View Patient {patientId} of Doctor {doctorId}
</Link>
</li>
))}
</ul>
</div>
);
};
export default DoctorList;
PortfolioSummary.tsx:
import React from 'react';
import { Asset } from '../store/useAssetStore';
interface Props {
assets: Asset[];
}
const PortfolioSummary: React.FC<Props> = ({ assets }) => {
const totalValue = assets.reduce((sum, a) => sum + a.value, 0);
const avgChange =
assets.length > 0
? assets.reduce((sum, a) => sum + a.changePercent, 0) / assets.length
: 0;
return (
<div>
<h2>Portfolio Summary</h2>
<p>Total Value: ${totalValue.toFixed(2)}</p>
<p>Average Change: {avgChange.toFixed(2)}%</p>
</div>
);
};
export default PortfolioSummary;
DoctorPatientDetails.tsc:
import React from 'react';
import { useParams } from 'react-router-dom';
const DoctorPatientDetails: React.FC = () => {
const { doctorId, patientId } = useParams<{ doctorId: string; patientId: string }>();
const isValid =
doctorId !== undefined &&
patientId !== undefined &&
!isNaN(Number(doctorId)) &&
!isNaN(Number(patientId));
if (!isValid) {
return <p>Error: Invalid doctor or patient ID</p>;
}
return (
<div>
<h2>Doctor ID: {doctorId}</h2>
<h2>Patient ID: {patientId}</h2>
</div>
);
};
export default DoctorPatientDetails;
EditAsset.ts:
import React from 'react';
import { useParams } from 'react-router-dom';
import AssetEditor from '../AssetEditor';
import { Asset } from '../store/useAssetStore';
interface EditAssetProps {
assets: Asset[];
onUpdate: (asset: Asset) => void;
}
const EditAsset: React.FC<EditAssetProps> = ({ assets, onUpdate }) => {
const { symbol } = useParams<{ symbol: string }>();
const asset = assets.find((a) => a.symbol === symbol);
return asset ? (
<AssetEditor existingAsset={asset} onUpdate={onUpdate} />
):(
<p>Asset not found</p>
);
};
export default EditAsset;
AddAsset.tsx:
import React from 'react';
import { useNavigate } from 'react-router-dom';
import AssetEditor from '../AssetEditor';
import { Asset } from '../store/useAssetStore';
interface AddAssetProps {
onUpdate: (asset: Asset) => void;
}
const AddAsset: React.FC<AddAssetProps> = ({ onUpdate }) => {
const navigate = useNavigate();
const handleUpdate = (asset: Asset) => {
onUpdate(asset);
navigate('/');
};
return <AssetEditor onUpdate={handleUpdate} />;
};
export default AddAsset;
AssetEditor.tsx:
import React, { ChangeEvent } from 'react';
export type Asset = {
name: string;
symbol: string;
value: number;
changePercent: number;
};
interface AssetEditorProps {
onUpdate: (asset: Asset) => void;
existingAsset?: Asset;
}
interface AssetEditorState {
name: string;
symbol: string;
value: string;
changePercent: string;
}
class AssetEditor extends React.Component<AssetEditorProps, AssetEditorState> {
constructor(props: AssetEditorProps) {
super(props);
this.state = {
name: props.existingAsset?.name || '',
symbol: props.existingAsset?.symbol || '',
value: props.existingAsset?.value?.toString() || '',
changePercent: props.existingAsset?.changePercent?.toString() || ''
};
}
handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
this.setState({ [name]: value } as Pick<AssetEditorState, keyof AssetEditorState>);
};
handleSubmit = () => {
const { name, symbol, value, changePercent } = this.state;
const asset: Asset = {
name,
symbol,
value: parseFloat(value),
changePercent: parseFloat(changePercent)
};
this.props.onUpdate(asset);
this.setState({
name: '',
symbol: '',
value: '',
changePercent: ''
});
};
render() {
return (
<div>
<input name="name" placeholder="Name" value={this.state.name}
onChange={this.handleChange} />
<input name="symbol" placeholder="Symbol" value={this.state.symbol}
onChange={this.handleChange} />
<input name="value" placeholder="Value" value={this.state.value}
onChange={this.handleChange} />
<input name="changePercent" placeholder="Change %"
value={this.state.changePercent} onChange={this.handleChange} />
<button onClick={this.handleSubmit}>
{this.props.existingAsset ? 'Update' : 'Add'}
</button>
</div>
);
}
}
export default AssetEditor;
App.tsx:
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import PortfolioSummary from './components/PortfolioSummary';
import EditAsset from './components/EditAsset';
import AddAsset from './components/AddAsset';
import DoctorPatientDetails from './components/DoctorPatientDetails';
import DoctorList from './components/DoctorList';
import { useAssetStore } from './store/useAssetStore';
import { DoctorProvider } from './context/DoctorContext';
const App: React.FC = () => {
const assets = useAssetStore((state) => state.assets);
const updateAsset = useAssetStore((state) => state.updateAsset);
return (
<DoctorProvider>
<Router>
<Routes>
<Route path="/" element={<PortfolioSummary assets={assets} />} />
<Route path="/add" element={<AddAsset onUpdate={updateAsset} />} />
<Route path="/edit/:symbol" element={<EditAsset assets={assets}
onUpdate={updateAsset} />} />
<Route path="/doctors" element={<DoctorList />} />
<Route path="/doctors/:doctorId/patients/:patientId" element={<DoctorPatientDetails
/>} />
</Routes>
</Router>
</DoctorProvider>
);
};
export default App;
Output:
Initial View (Doctor List Page - /):
Displays a list of doctors and their associated patients as clickable links.
Example:
Doctors
- View Dr. Smith's record for John Doe
- View Dr. Smith's record for Jane Roe
- View Dr. Johnson's record for John Doe
- View Dr. Johnson's record for Jane Roe
Each link is styled with blue text and underlines on hover.
Clicking a link navigates to /doctors/:doctorId/patients/:patientId and updates the
Zustand store with the selected IDs.
Valid Doctor/Patient Details Page (e.g., /doctors/1/patients/1):
Displays details for the selected doctor and patient.
Example:
Doctor: Dr. Smith | Patient: John Doe
Doctor ID: 1
Patient ID: 1
Back to Doctor List
The "Back to Doctor List" link is styled with blue text and underlines on hover.
The Zustand store is updated with selectedDoctorId: 1 and selectedPatientId: 1.
Invalid ID Scenario (e.g., /doctors/abc/patients/1 or /doctors/3/patients/1):
Displays an error message if either ID is non-numeric or if the doctor/patient is not
found.
Example:
Error: Invalid doctor or patient ID
Both IDs must be numeric values.
Back to Doctor List
Alternatively, if the IDs are numeric but not found:
Error: Doctor or Patient not found
Back to Doctor List
The error message is in red text, and the "Back to Doctor List" link is styled as abov
Section 4:
1. Create a Zustand store for notifications:
Each notification has id , message , type ( 'info' | 'error' | 'success' ), and read: boolean
.
Add actions: addNotification , markAsRead , and clearNotifications .
2. Use the store in a NotificationList component to display unread notifications and mark
them as read.
Advanced State Management with Zustand: Middleware, Persistence, and Async
Patterns
A:
npm install zustand
npm install zustand middleware
src/store/useNotificationStore.ts:
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
type NotificationType = 'info' | 'error' | 'success';
interface Notification {
id: string;
message: string;
type: NotificationType;
read: boolean;
}
interface NotificationStore {
notifications: Notification[];
addNotification: (notification: Omit<Notification, 'read'>) => void;
markAsRead: (id: string) => void;
clearNotifications: () => void;
}
export const useNotificationStore = create<NotificationStore>()(
devtools((set) => ({
notifications: [],
addNotification: (notification) =>
set((state) => ({
notifications: [
...state.notifications,
{ ...notification, read: false },
],
})),
markAsRead: (id) =>
set((state) => ({
notifications: state.notifications.map((n) =>
n.id === id ? { ...n, read: true } : n
),
})),
clearNotifications: () => set({ notifications: [] }),
}))
);
src/components/NotificationList.tsx:
import React from 'react';
import { useNotificationStore } from '../store/useNotificationStore';
const NotificationList: React.FC = () => {
const { notifications, markAsRead } = useNotificationStore();
const unread = notifications.filter((n) => !n.read);
return (
<div>
<h2>Unread Notifications</h2>
{unread.length === 0 && <p>No unread notifications.</p>}
<ul>
{unread.map((notification) => (
<li key={notification.id} style={{ color: getColor(notification.type) }}>
{notification.message}
<button onClick={() => markAsRead(notification.id)}>Mark as read</button>
</li>
))}
</ul>
</div>
);
};
const getColor = (type: 'info' | 'error' | 'success') => {
switch (type) {
case 'info': return 'blue';
case 'error': return 'red';
case 'success': return 'green';
default: return 'black';
}
};
export default NotificationList;
App.tsx:
import React from 'react';
import NotificationList from './components/NotificationList';
import { useNotificationStore } from './store/useNotificationStore';
const App: React.FC = () => {
const addNotification = useNotificationStore((state) => state.addNotification);
return (
<div>
<h1>Dashboard</h1>
<button onClick={() =>
addNotification({
id: Date.now().toString(),
message: 'Sample Info Notification',
type: 'info',
})
}>
Add Notification
</button>
<NotificationList />
</div>
);
};
export default App;
Output:
1. Create a persisted Zustand store for user session:
Fields: userId: string , token: string , expiresAt: number
Only persist userId and token , not expiresAt
Add a migration to handle a new field, role: 'admin' | 'user' (default ‘user’), in version
2.
2. Use devtools and immer middleware for a note history log:
Actions: addHistoryEntry , clearHistory
Log each entry as { noteId: string, action: string, timestamp: number }
3. Combine Zustand and React Query:
Fetch a list of collaborators from an API.
Store collaborators in Zustand.
Display collaborators in a component, updating automatically when data is fetched.
Zustand Slices & Modular State Architecture: Scaling a Collaborative Design
Platform
A:
// stores/sessionStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
type Role = 'admin' | 'user';
interface SessionState {
userId: string;
token: string;
expiresAt: number;
role: Role;
setSession: (data: { userId: string; token: string; expiresAt: number; role?: Role }) => void;
clearSession: () => void;
}
export const useSessionStore = create<SessionState>()(
persist(
(set) => ({
userId: '',
token: '',
expiresAt: 0,
role: 'user',
setSession: ({ userId, token, expiresAt, role = 'user' }) =>
set({ userId, token, expiresAt, role }),
clearSession: () =>
set({ userId: '', token: '', expiresAt: 0, role: 'user' }),
}),
{
name: 'session-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
userId: state.userId,
token: state.token,
role: state.role,
}),
version: 2,
migrate: (persistedState: any, version) => {
if (version < 2) {
return {
...persistedState,
role: 'user',
};
}
return persistedState;
},
}
)
);
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { produce } from 'immer';
interface HistoryEntry {
noteId: string;
action: string;
timestamp: number;
}
interface NoteHistoryState {
history: HistoryEntry[];
addHistoryEntry: (entry: HistoryEntry) => void;
clearHistory: () => void;
}
export const useNoteHistoryStore = create<NoteHistoryState>()(
devtools((set) => ({
history: [],
addHistoryEntry: (entry) =>
set(
produce((state: NoteHistoryState) => {
state.history.push(entry);
}),
false,
'addHistoryEntry'
),
clearHistory: () =>
set(
produce((state: NoteHistoryState) => {
state.history = [];
}),
false,
'clearHistory'
),
}))
);
// store/collaboratorStore.ts
import { create } from 'zustand';
import { Collaborator } from '../types';
interface CollaboratorStore {
collaborators: Collaborator[];
setCollaborators: (data: Collaborator[]) => void;
}
export const useCollaboratorStore = create<CollaboratorStore>((set) => ({
collaborators: [],
setCollaborators: (data) => set({ collaborators: data }),
}));
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { Collaborator } from '../types';
const fetchCollaborators = async (): Promise<Collaborator[]> => {
const res = await fetch('/api/collaborators'); // Replace with actual API
if (!res.ok) throw new Error('Failed to fetch collaborators');
return res.json();
};
export const useFetchCollaborators = (
setCollaborators: (data: Collaborator[]) => void
) => {
const { data, ...rest } = useQuery<Collaborator[]>({
queryKey: ['collaborators'],
queryFn: fetchCollaborators,
});
useEffect(() => {
if (data) {
setCollaborators(data);
}
}, [data, setCollaborators]);
return { data, ...rest };
};
import React, { useEffect } from 'react';
import { useFetchCollaborators } from '../hooks/useFetchCollaborators';
import { useCollaboratorStore } from '../store/collaboratorStore'; // or wherever your state
lives
const CollaboratorList = () => {
const { setCollaborators, collaborators } = useCollaboratorStore();
const { data, isLoading, isError } = useFetchCollaborators(setCollaborators);
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error loading collaborators.</div>;
return (
<ul>
{collaborators.map((collab) => (
<li key={collab.id}>{collab.name} ({collab.email})</li>
))}
</ul>
);
};
export default CollaboratorList;
types.ts:
export interface Collaborator {
id: string;
name: string;
email: string;
}
import React from 'react';
import NotificationList from './components/NotificationList';
import { useNotificationStore } from './store/useNotificationStore';
const App: React.FC = () => {
const addNotification = useNotificationStore((state) => state.addNotification);
return (
<div>
<h1>Dashboard</h1>
<button onClick={() =>
addNotification({
id: Date.now().toString(),
message: 'Sample Info Notification',
type: 'info',
})
}>
Add Notification
</button>
<NotificationList />
</div>
);
};
export default App;
Output:
Persisted Zustand Store for User Session:
Initial State (v1):
{
"state": {
"userId": "123",
"token": "abc-token",
"expiresAt": 1625097600000
},
"version": 1
}
Stored in localStorage as session-storage.
After Migration to v2 (adding role):
{
"state": {
"userId": "123",
"token": "abc-token",
"role": "user",
"expiresAt": 1625097600000
},
"version": 2
}
Only userId, token, and role are persisted in localStorage.
Accessing Store:
useSessionStore.getState().userId → "123"
useSessionStore.getState().role → "user"
useSessionStore.getState().expiresAt → 1625097600000 (not persisted)
Note History Log with Devtools and Immer:
Initial History State:
{
"history": []
}
After Adding History Entry (e.g., addHistoryEntry("note1", "created")):
{
"history": [
{
"noteId": "note1",
"action": "created",
"timestamp": 1625097600000
}
]
}
After Multiple Entries:
{
"history": [
{
"noteId": "note1",
"action": "created",
"timestamp": 1625097600000
},
{
"noteId": "note2",
"action": "updated",
"timestamp": 1625097601000
}
]
}
After clearHistory):
{
"history": []
}
Devtools Output (in Redux DevTools):
Actions logged as @@zustand/addHistoryEntry and @@zustand/clearHistory with state
diffs.
Example: Adding an entry logs the new history item with timestamp.
Combined Zustand and React Query (Collaborators):
Fetching Collaborators (Loading):
Loading collaborators...
Fetch Error:
Error: Error fetching collaborators: Failed to fetch collaborators: Not Found
Successful Fetch (API returns [{ id: "1", name: "Alice" }, { id: "2", name: "Bob" }]):
Zustand Store:
json
{
"collaborators": [
{ "id": "1", "name": "Alice" },
{ "id": "2", "name": "Bob" }
]
}
Component Output:
Collaborators
- Alice (ID: 1)
- Bob (ID: 2)
Section 6:
1. Create a notificationsSlice :
Fields: notifications: { id: string; message: string; read: boolean }[]
Actions: addNotification , markAsRead , clearNotifications
2. Add the slice to the main store.
3. Build a NotificationsPanel component that displays unread notifications and lets
users mark them as read
A:
// notificationsSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export type Notification = {
id: string;
message: string;
read: boolean;
};
type NotificationsState = {
notifications: Notification[];
};
const initialState: NotificationsState = {
notifications: [],
};
const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {
addNotification: (state, action: PayloadAction<Notification>) => {
state.notifications.push(action.payload);
},
markAsRead: (state, action: PayloadAction<string>) => {
const n = state.notifications.find(n => n.id === action.payload);
if (n) n.read = true;
},
clearNotifications: (state) => {
state.notifications = [];
},
},
});
export const { addNotification, markAsRead, clearNotifications } =
notificationsSlice.actions;
export default notificationsSlice.reducer;
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import type { RootState } from '../store';
import { markAsRead, clearNotifications } from '../store/notificationsSlice';
import './NotificationsPanel.css';
const NotificationsPanel: React.FC = () => {
const dispatch = useDispatch();
const notifications = useSelector((state: RootState) =>
state.notifications.notifications.filter((n) => !n.read)
);
const handleMarkAsRead = (id: string) => {
dispatch(markAsRead(id));
};
const handleClearAll = () => {
dispatch(clearNotifications());
};
if (notifications.length === 0) {
return (
<div className="notifications-panel">
<h3>🔔 Notifications</h3>
<p className="empty-msg">You're all caught up! 🎉</p>
</div>
);
}
return (
<div className="notifications-panel">
<h3>🔔 Notifications</h3>
<ul>
{notifications.map((n) => (
<li key={n.id} className="notification-item">
<span>{n.message}</span>
<button onClick={() => handleMarkAsRead(n.id)}>✅ Mark as Read</button>
</li>
))}
</ul>
<button className="clear-btn" onClick={handleClearAll}>
Clear All
</button>
</div>
);
};
export default NotificationsPanel;
.notifications-panel {
background-color: #f0f4ff;
padding: 1rem;
margin: 1rem;
border-radius: 12px;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.08);
max-width: 400px;
}
.notifications-panel h3 {
margin-bottom: 0.75rem;
}
.notification-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
background: #fff;
padding: 0.5rem 0.75rem;
border-radius: 8px;
}
.notification-item button {
background-color: #007bff;
color: white;
border: none;
padding: 0.3rem 0.6rem;
border-radius: 6px;
cursor: pointer;
}
.notification-item button:hover {
background-color: #0056b3;
}
.clear-btn {
margin-top: 1rem;
padding: 0.5rem 1rem;
border: none;
border-radius: 8px;
background-color: #dc3545;
color: white;
cursor: pointer;
}
.clear-btn:hover {
background-color: #c82333;
}
.empty-msg {
color: gray;
}
// index.ts
import { configureStore } from '@reduxjs/toolkit';
import notificationsReducer from './notificationsSlice';
export const store = configureStore({
reducer: {
notifications: notificationsReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
App.tsx:
import React from 'react';
import NotificationList from './components/NotificationList';
import { useNotificationStore } from './store/useNotificationStore';
const App: React.FC = () => {
const addNotification = useNotificationStore((state) => state.addNotification);
return (
<div>
<h1>Dashboard</h1>
<button
onClick={() =>
addNotification({
id: Date.now().toString(),
message: 'Sample Info Notification',
type: 'info', // Don't include 'read' here
})
}
>
Add Notification
</button>
<NotificationList />
</div>
);
};
export default App;
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
type NotificationType = 'info' | 'error' | 'success';
interface Notification {
id: string;
message: string;
type: NotificationType;
read: boolean;
}
interface NotificationStore {
notifications: Notification[];
addNotification: (notification: Omit<Notification, 'read'>) => void;
markAsRead: (id: string) => void;
clearNotifications: () => void;
}
export const useNotificationStore = create<NotificationStore>()(
devtools((set) => ({
notifications: [],
addNotification: (notification) =>
set((state) => ({
notifications: [
...state.notifications,
{ ...notification, read: false },
],
})),
markAsRead: (id) =>
set((state) => ({
notifications: state.notifications.map((n) =>
n.id === id ? { ...n, read: true } : n
),
})),
clearNotifications: () => set({ notifications: [] }),
}))
);
import React from 'react';
import { useNotificationStore } from '../store/useNotificationStore';
const NotificationList: React.FC = () => {
const { notifications, markAsRead, clearNotifications } = useNotificationStore();
const unreadNotifications = notifications.filter((n) => !n.read);
return (
<div>
<h2>Notifications</h2>
{unreadNotifications.length === 0 ? (
<p>No unread notifications.</p>
):(
<ul>
{unreadNotifications.map((n) => (
<li key={n.id}>
<strong>{n.type.toUpperCase()}</strong>: {n.message}
<button onClick={() => markAsRead(n.id)}>Mark as read</button>
</li>
))}
</ul>
)}
<button onClick={clearNotifications}>Clear All</button>
</div>
);
};
export default NotificationList;
types.tsx:
export interface Collaborator {
id: string;
name: string;
email: string;
}
useFetchCollaborators.ts:
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { Collaborator } from '../types';
const fetchCollaborators = async (): Promise<Collaborator[]> => {
const res = await fetch('/api/collaborators'); // Replace with actual API
if (!res.ok) throw new Error('Failed to fetch collaborators');
return res.json();
};
export const useFetchCollaborators = (
setCollaborators: (data: Collaborator[]) => void
) => {
const { data, ...rest } = useQuery<Collaborator[]>({
queryKey: ['collaborators'],
queryFn: fetchCollaborators,
});
useEffect(() => {
if (data) {
setCollaborators(data);
}
}, [data, setCollaborators]);
return { data, ...rest };
};
CollaboratorList.tsx:
import React, { useEffect } from 'react';
import { useFetchCollaborators } from '../hooks/useFetchCollaborators';
import { useCollaboratorStore } from '../store/collaboratorStore'; // or wherever your state
lives
const CollaboratorList = () => {
const { setCollaborators, collaborators } = useCollaboratorStore();
const { data, isLoading, isError } = useFetchCollaborators(setCollaborators);
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error loading collaborators.</div>;
return (
<ul>
{collaborators.map((collab) => (
<li key={collab.id}>{collab.name} ({collab.email})</li>
))}
</ul>
);
};
export default CollaboratorList;
// store/collaboratorStore.ts
import { create } from 'zustand';
import { Collaborator } from '../types';
interface CollaboratorStore {
collaborators: Collaborator[];
setCollaborators: (data: Collaborator[]) => void;
}
export const useCollaboratorStore = create<CollaboratorStore>((set) => ({
collaborators: [],
setCollaborators: (data) => set({ collaborators: data }),
}));
Output:
When we click mark as read notification will go from here and If we click clear all, all
notifications gets cleared..