Day14 Assignment
Day14 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
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 };
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:
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;
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;
}
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;
}
return asset ? (
<AssetEditor existingAsset={asset} onUpdate={onUpdate} />
):(
<p>Asset not found</p>
);
};
src/App.tsx:
import React, { useState } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
type Asset = {
name: string;
symbol: string;
value: number;
changePercent: number;
};
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>
);
};
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';
interface AssetState {
assets: Asset[];
updateAsset: (newAsset: Asset) => void;
}
Src/context/DoctorContext.tsx:
import React, { createContext, useContext, useState } from 'react';
interface DoctorContextType {
selectedDoctorId: number | null;
setSelectedDoctorId: (id: number) => void;
}
return (
<DoctorContext.Provider value={{ selectedDoctorId, setSelectedDoctorId }}>
{children}
</DoctorContext.Provider>
);
};
Src/components/DoctorList.tsx
import React from 'react';
import { Link } from 'react-router-dom';
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>
);
};
PortfolioSummary.tsx:
import React from 'react';
import { Asset } from '../store/useAssetStore';
interface Props {
assets: Asset[];
}
return (
<div>
<h2>Portfolio Summary</h2>
<p>Total Value: ${totalValue.toFixed(2)}</p>
<p>Average Change: {avgChange.toFixed(2)}%</p>
</div>
);
};
DoctorPatientDetails.tsc:
import React from 'react';
import { useParams } from 'react-router-dom';
if (!isValid) {
return <p>Error: Invalid doctor or patient ID</p>;
}
return (
<div>
<h2>Doctor ID: {doctorId}</h2>
<h2>Patient ID: {patientId}</h2>
</div>
);
};
interface EditAssetProps {
assets: Asset[];
onUpdate: (asset: Asset) => void;
}
return asset ? (
<AssetEditor existingAsset={asset} onUpdate={onUpdate} />
):(
<p>Asset not found</p>
);
};
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;
}
AssetEditor.tsx:
import React, { ChangeEvent } from 'react';
interface AssetEditorProps {
onUpdate: (asset: Asset) => void;
existingAsset?: Asset;
}
interface AssetEditorState {
name: string;
symbol: string;
value: string;
changePercent: string;
}
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>
);
}
}
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:
Doctors
Doctor ID: 1
Patient ID: 1
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.
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';
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;
}
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;
}
// store/collaboratorStore.ts
import { create } from 'zustand';
import { Collaborator } from '../types';
interface CollaboratorStore {
collaborators: Collaborator[];
setCollaborators: (data: Collaborator[]) => void;
}
useEffect(() => {
if (data) {
setCollaborators(data);
}
}, [data, setCollaborators]);
types.ts:
export interface Collaborator {
id: string;
name: string;
email: string;
}
return (
<div>
<h1>Dashboard</h1>
<button onClick={() =>
addNotification({
id: Date.now().toString(),
message: 'Sample Info Notification',
type: 'info',
})
}>
Add Notification
</button>
<NotificationList />
</div>
);
};
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';
type NotificationsState = {
notifications: Notification[];
};
const initialState: NotificationsState = {
notifications: [],
};
if (notifications.length === 0) {
return (
<div className="notifications-panel">
<h3>🔔 Notifications</h3>
return (
<div className="notifications-panel">
<h3>🔔 Notifications</h3>
<ul>
{notifications.map((n) => (
<li key={n.id} className="notification-item">
<span>{n.message}</span>
.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';
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>
);
};
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;
}
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>
);
};
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';
useEffect(() => {
if (data) {
setCollaborators(data);
}
}, [data, setCollaborators]);
CollaboratorList.tsx:
import React, { useEffect } from 'react';
import { useFetchCollaborators } from '../hooks/useFetchCollaborators';
import { useCollaboratorStore } from '../store/collaboratorStore'; // or wherever your state
lives
return (
<ul>
{collaborators.map((collab) => (
<li key={collab.id}>{collab.name} ({collab.email})</li>
))}
</ul>
);
};
// store/collaboratorStore.ts
import { create } from 'zustand';
import { Collaborator } from '../types';
interface CollaboratorStore {
collaborators: Collaborator[];
setCollaborators: (data: Collaborator[]) => void;
}
When we click mark as read notification will go from here and If we click clear all, all
notifications gets cleared..