0% found this document useful (0 votes)
9 views55 pages

Day14 Assignment

The document outlines the structure and implementation of a budget tracker application using React and TypeScript. It includes components for tracking income and expenses, displaying portfolio summaries, and editing assets, with a focus on type safety and state management using useReducer and Zustand. Additionally, it covers routing for navigating between components and validating parameters in URLs.

Uploaded by

y22cs035
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
9 views55 pages

Day14 Assignment

The document outlines the structure and implementation of a budget tracker application using React and TypeScript. It includes components for tracking income and expenses, displaying portfolio summaries, and editing assets, with a focus on type safety and state management using useReducer and Zustand. Additionally, it covers routing for navigating between components and validating parameters in URLs.

Uploaded by

y22cs035
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 55

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..

You might also like