DAY 28: Mastering useId and useReducer Hooks β Build a Real-Time Expense Tracker with Validation and Persistence | My Web Dev Journey β ReactJS


π Introduction
Welcome to Day 28 of my Web Development Journey!
After building a solid foundation with HTML, CSS, and JavaScript, Iβve been diving deep into ReactJS β one of the most powerful libraries for creating dynamic user interfaces.
Recently, I focused on mastering key React concepts like the useId Hook, useReducer Hook, and applied these by building a practical Expense Tracker project.
Iβm sharing this journey publicly to keep myself accountable and to help others who are learning React from scratch.
π Hereβs What I Covered Over the Last 3 Days:
Day 25:
- Learned about the
useId
Hook - Explored the
useReducer
Hook
Day 26:
- Built the Expense Tracker app
- Implemented adding transactions
- Displayed transactions on the UI
- Calculated and updated balances in real-time
Day 27:
- Added delete transaction functionality
- Integrated React Context API for global state
- Persisted data with
localStorage
- Created a custom
useLocalStorage
Hook
Letβs dive deeper into each of these topics below π
1. useId Hook:
useId is a built-in React hook introduced in React 18.
It generates a unique and stable ID that remains consistent across the server and client, making it ideal for accessibility-related use cases.
What Problem Does useId
Solve?
In client-rendered apps, we might use Math.random()
or Date.now()
to generate unique IDs β but these values can mismatch during SSR (Server Side Rendering) and cause hydration errors.
useId
solves this by generating consistent unique IDs across server and client.
When Should We Use useId
?
Use useId
when we need:
- A stable and unique ID for associating form elements with labels
- IDs for accessibility features like
aria-labelledby
,aria-describedby
- Consistent behavior across server and client (important for SSR)
Not suitable for dynamic lists where keys change β use explicit keys like
item.id
orindex
instead.
Example: Using useId
with an Input Field
import React, { useId } from 'react';
function NameInput() {
const id = useId();
return (
<div>
<label htmlFor={id}>Name:</label>
<input id={id} type="text" placeholder="Enter your name" />
</div>
);
}
Explanation:
useId()
generates a unique ID like:r0:
or:r1:
- This ID is linked between the
label
andinput
, improving accessibility - It works consistently in both client and server environments (ideal for SSR)
useId Is Not Meant for Dynamic Keys:
While useId
is great for static and stable identifiers (like linking labels to form inputs), it's not suitable for dynamic or runtime-generated keys.
Donβt Use useId
For:
- Keys in
.map()
when rendering lists dynamically - Keys for dynamic component rendering
- Generating unique IDs for API calls, sessions, or form submissions
- Values that must change based on user interaction or logic
Use These Instead:
uuid
nanoid
- Server-generated unique identifiers (e.g.,
_id
from a database) - Any custom logic-based ID generation
useId
is deterministic and static β it should not be used where uniqueness must depend on changing data or runtime behavior.
Final Thoughts:
useId
is great for stable, unique IDs mainly for accessibility (e.g., linking labels and inputs).- Avoid using
useId
for dynamic keys in lists or changing data. - For dynamic IDs, use libraries like
uuid
ornanoid
. - Understanding when to use
useId
helps write better and more accessible React code.
2. useReducer Hook:
useReducer is a React hook used to manage complex state logic in a more predictable way compared to useState
. Itβs especially useful when state depends on previous state or involves multiple sub-values.
Syntax:
const [state, dispatch] = useReducer(reducer, initialState);
- state: The current state object.
- dispatch: A function to send actions to the reducer.
- reducer: A function that takes the current state and an action and returns a new state.
- initialState: The initial value of the state.
How It Works:
- We define a reducer function that handles various action types and updates state accordingly.
- We call dispatch with an action object to trigger state changes.
- The reducer processes the action and returns a new state based on the action type and payload.
Dispatch Function:
dispatch
is a function used to send an action to the reducer.- Calling
dispatch(action)
triggers the reducer to process the action and update the state. - It acts like a messenger that tells the reducer what change to perform.
Action Object & Payload:
- The action is an object with at least a
type
property describing what kind of update to perform. - It can optionally include a
payload
property carrying data needed for the update.
When to Use useReducer:
- When managing complex state logic involving multiple sub-values or nested data.
- When the next state depends on the previous state.
- When we want to centralize state updates in one place instead of multiple
useState
calls. - When we need to trace state changes clearly through action types and reducer logic.
- When building more scalable or maintainable components that require structured state management.
Example: Counter using useReducer
import { useReducer } from "react";
const ACTIONS = {
INCREMENT: "increment",
DECREMENT: "decrement",
};
function reducer(state, action) {
switch (action.type) {
case ACTIONS.INCREMENT:
return { count: state.count + 1 };
case ACTIONS.DECREMENT:
return { count: state.count - 1 };
default:
return state;
}
}
const Counter = () => {
const [state, dispatch] = useReducer(reducer, { count: 0 });
function increment() {
dispatch({ type: ACTIONS.INCREMENT });
}
function decrement() {
dispatch({ type: ACTIONS.DECREMENT });
}
return (
<div>
<h1>Count : {state.count}</h1>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</div>
);
};
export default Counter;
How It Works:
- We define a
reducer
function that updates state based on the action type. - The
useReducer
hook initializes state (count: 0
) and provides adispatch
function. - On button click, we call
dispatch()
with an action (increment
ordecrement
). - The
reducer
handles that action and returns a new state. - React re-renders the UI with the updated
count
.
Final Thoughts:
useReducer
is a powerful alternative touseState
when dealing with complex state logic.- It promotes cleaner and more predictable state management using actions and reducers.
- Ideal for larger components or apps where state transitions need to be centralized.
3. Expense Tracker App:
After learning about React hooks like useId
and useReducer
, I wanted to put my skills to the test by building something practical. That led me to create an Expense Tracker App β a simple yet functional tool to record transactions and track balances in real-time.
Project Overview:
This Expense Tracker lets users:
- Add income or expense transactions with descriptions and amounts
- View a real-time running total balance along with separate income and expense breakdowns
- Delete any transaction easily with a single click
- Persist all transaction data locally using
localStorage
so data is saved across sessions - Get instant validation feedback on form inputs, ensuring descriptions are meaningful and amounts are valid numbers
- Enjoy a responsive and clean user interface with clear visual distinction between income (positive) and expenses (negative)
- Benefit from a disabled submit button until the form inputs are correctly filled, preventing invalid entries
- Experience smooth, immediate UI updates on any data change without needing to refresh
The app is entirely built using React functional components and hooks, without any external state management libraries or backend services β making it lightweight, efficient, and easy to maintain.
Core Features:
Transaction Management
- Add Income/Expense Transactions β Users can add new transactions with description and amount.
- Form Validation β Comprehensive validation for description (min 2 characters) and amount (valid number format).
- Unique ID Generation β Each transaction gets a unique ID using
crypto.randomUUID()
. - Delete Transactions β Remove transactions with a single click using the delete button.
- Real-time Updates β UI updates immediately when transactions are added or deleted.
Financial Calculations
- Auto-calculated Balance β Automatically calculates total balance, total income, and total expenses.
- Income/Expense Separation β Positive amounts are treated as income, negative amounts as expenses.
- Dynamic Balance Display β Shows current financial status with income, expense, and total balance.
Data Persistence
- Local Storage Integration β All transactions are saved to browser's
localStorage
. - Persistent Data β Transactions persist across browser sessions and page refreshes.
- Custom
useLocalStorage
Hook β Custom React hook for seamless localStorage integration.
User Interface
- Clean, Modern Design β Responsive and user-friendly interface.
- Form Validation Feedback β Real-time error messages for invalid inputs.
- Disabled Submit Button β Submit button is disabled when form is invalid or empty.
- Transaction List Display β Clear list showing all transactions with amounts and descriptions.
- Delete Confirmation β Visual delete buttons for each transaction.
Technical Implementation
- React Context API β Global state management using
TransactionContext
. - Custom Hooks β Reusable
useLocalStorage
hook for data persistence. - Utility Functions β Modular functions for balance calculation and amount formatting.
- Component Architecture β Well-organized component structure with separation of concerns.
- Error Handling β Proper validation and error state management.
Code Quality
- Modular Structure β Clean separation between components, context, hooks, and utilities.
- Reusable Components β
FormGroup
component for consistent form inputs. - Type Safety β Proper prop handling and state management.
- Performance Optimized β Efficient re-renders and state updates.
React Concepts Applied:
useState
β for managing form data, errors, and local app stateuseEffect
β to update balances whenever the transaction list changesuseContext
&createContext
β to implement global transaction state via the Context API- Custom hook:
useLocalStorage
β for reading and writing tolocalStorage
seamlessly useId
β to generate unique and accessible input field IDs- Component composition β breaking UI into reusable components like
FormGroup
,TransactionItem
,Balance
, etc. - Props handling β for passing data and callbacks between components
- Conditional rendering β to handle UI behaviors like disabling the submit button
- Dynamic styling β applying styles based on transaction type (income vs expense)
Folder Structure:
src/
βββ App.jsx
βββ index.css
βββ main.jsx
βββ components/
β βββ Balance.jsx
β βββ BalanceContainer.jsx
β βββ FormGroup.jsx
β βββ TransactionContainer.jsx
β βββ TransactionForm.jsx
β βββ TransactionItem.jsx
β βββ Transactions.jsx
βββ context/
β βββ TransactionContext.jsx
β βββ useTransactionContext.js
βββ hooks/
β βββ useLocalStorage.js
βββ utility/
βββ calculateBalance.js
βββ formatAmount.js
βββ initLocalStorage.js
Code Walkthrough:
1. Application Structure & Context Setup
The App
component is the root of my application. It pulls transaction data from the global context using a custom hook and uses useEffect
to recalculate the balance whenever the transaction list changes.
TransactionProvider
wraps your entire app to provide access to the shared transaction state (transactions
and setTransactions
) via Reactβs Context API, allowing components deep in the tree to access and modify this data without prop drilling.
Main App Component
// src/App.jsx
const App = () => {
const { transactions } = useTransactionContext();
const [allBalance, setAllBalance] = useState({
total: 0, income: 0, expense: 0,
});
useEffect(() => {
setAllBalance(calculateBalance(transactions));
}, [transactions]);
return (
<>
<h1>Expense Tracker</h1>
<BalanceContainer allBalance={allBalance} />
<TransactionContainer />
</>
);
};
Context Provider
// src/context/TransactionContext.jsx
const TransactionProvider = ({ children }) => {
const [transactions, setTransactions] = useLocalStorage("allTransaction", []);
return (
<TransactionContext.Provider value={{ transactions, setTransactions }}>
{children}
</TransactionContext.Provider>
);
};
2. Data Persistence with Custom Hook
The useLocalStorage
hook abstracts away the logic for syncing React state with localStorage
.
It ensures that all data persists across page reloads and browser sessions, improving the user experience by keeping transaction data intact even after closing or refreshing the browser.
useLocalStorage Custom Hook
// src/hooks/useLocalStorage.js
export function useLocalStorage(key, initialData) {
const [data, setData] = useState(() => initLocalStorage(key, initialData));
const updateLocalStorage = (newData) => {
const valueToStore = typeof newData === "function" ? newData(data) : newData;
localStorage.setItem(key, JSON.stringify(valueToStore));
setData(valueToStore);
};
return [data, updateLocalStorage];
}
3. Transaction Form with Validation
Form state (formData
) holds input values, and errors
holds any validation messages.
A validationConfig
object defines rules for each input field (e.g., required, minimum length, valid number format).
Real-time validation triggers on input change, providing instant user feedback and preventing invalid form submissions.
Form State & Validation
// src/components/TransactionForm.jsx
const [formData, setFormData] = useState({ description: "", amount: "" });
const [errors, setErrors] = useState({});
const validationConfig = {
description: [
{ required: true, message: "Please enter description" },
{ minLength: 2, message: "Description must be at least 2 characters long" },
],
amount: [
{ required: true, message: "Please enter an amount" },
{ pattern: /^[-+]?(?:\d+|\d*\.\d+)$/, message: "Enter a valid number" },
],
};
Form Submission
const handleSubmit = (e) => {
e.preventDefault();
const result = validate(formData);
if (Object.entries(result).length) return;
setTransactions((prev) => [
...prev,
{
id: crypto.randomUUID(),
desc: formData.description.trim(),
amount: formData.amount.trim(),
},
]);
setFormData({ description: "", amount: "" });
setErrors({});
};
Real-time Validation
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
// Real-time validation logic
const rules = validationConfig[name];
let errorMsg = "";
for (const rule of rules) {
if (rule.required && !value.trim()) {
errorMsg = rule.message;
break;
}
// ... other validation rules
}
setErrors((prev) => ({ ...prev, [name]: errorMsg }));
};
4. Transaction Deletion
The delete handler filters out the selected transaction based on its unique ID and updates the transaction list in the global state.
Since we use useLocalStorage
, the deletion is immediately reflected in both the UI and localStorage
.
Delete Handler
// src/components/TransactionItem.jsx
const handleDelete = () => {
const filteredItems = transactions.filter((txn) => txn.id !== id);
setTransactions(filteredItems);
};
5. Balance Calculation
The calculateBalance
utility cleanly separates the logic for calculating:
- Total balance
- Income (positive numbers)
- Expenses (negative numbers)
This keeps our main component logic clean and readable.
The formatAmount
utility ensures all amounts are shown as properly formatted USD currency.
It improves readability and gives the app a more professional appearance.
Balance Calculation Utility
// src/utility/calculateBalance.js
export function calculateBalance(transactions) {
const income = transactions
.filter((txn) => Number(txn.amount) >= 0)
.reduce((total, txn) => total + Number(txn.amount), 0);
const expense = transactions
.filter((txn) => Number(txn.amount) < 0)
.reduce((total, txn) => total + Number(txn.amount), 0);
return { total: income + expense, income, expense };
}
Amount Formatting
// src/utility/formatAmount.js
export function formatAmount(amount) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
}
6. Transaction List Rendering
Dynamically renders each transaction using the map()
function.
Applies conditional styling (income
or expense
) based on whether the transaction amount is positive or negative, making the UI visually intuitive.
Dynamic List Rendering
// src/components/Transactions.jsx
{transactions.map((transaction) => {
const transactionClass = Number(transaction.amount) >= 0 ? "income" : "expense";
return (
<TransactionItem
key={transaction.id}
transactionClass={transactionClass}
name={transaction.desc}
amount={transaction.amount}
id={transaction.id}
/>
);
})}
7. Context Consumption Hook
useTransactionContext
is a custom hook that encapsulates the logic for consuming the context.
It provides a clean and safe way to access global state from any component, with built-in error handling if used outside the provider.
useTransactionContext Hook
// src/context/useTransactionContext.js
export const useTransactionContext = () => {
const context = useContext(TransactionContext);
if (!context) {
throw new Error("useTransactionContext must be used within TransactionProvider");
}
return context;
};
8. Balance Display
BalanceContainer
and nested Balance
components separate display logic from business logic.
Each balance type (total, income, expense) is clearly labeled and styled differently for easy recognition.
Balance Container
// src/components/BalanceContainer.jsx
const BalanceContainer = ({ allBalance }) => {
return (
<div className="balance-container">
<Balance title="Your Balance" amount={allBalance.total} className="balance" />
<div className="income-expense">
<Balance title="Income" amount={allBalance.income} className="income" />
<Balance title="Expense" amount={allBalance.expense} className="expense" />
</div>
</div>
);
};
9. Key Implementation
Form Validation Logic
validate()
checks each field using rules from validationConfig
, returning a complete errors object.
This centralized validation logic makes our form reliable, extensible, and easier to maintain.
const validate = (formData) => {
const errorsData = {};
Object.entries(formData).forEach(([key, value]) => {
validationConfig[key].some((rule) => {
if (rule.required && !value.trim()) {
errorsData[key] = rule.message;
return true;
}
// ... other validation checks
});
});
setErrors(errorsData);
return errorsData;
};
Submit Button State
isSubmitDisabled()
disables the formβs submit button unless all validations pass and fields are non-empty.
This prevents invalid data from ever being submitted, which is great for data integrity.
const isSubmitDisabled = () => {
return (
!formData.description.trim() ||
!formData.amount.trim() ||
Object.values(errors).some((e) => e)
);
};
LocalStorage Initialization
initLocalStorage
checks if data exists under a specific key.
- If data exists, it parses and returns the stored data.
- If not, it initializes localStorage with a default value and saves it.
This ensures that useLocalStorage
always works with valid, initialized data.
// src/utility/initLocalStorage.js
export function initLocalStorage(key, initialData) {
const saved = localStorage.getItem(key);
if (saved !== null) {
return JSON.parse(saved);
}
localStorage.setItem(key, JSON.stringify(initialData));
return initialData;
}
Final Thoughts:
Building this Expense Tracker app was a great opportunity to apply and deepen my understanding of React fundamentals and hooks in a real-world scenario. Through this project, I practiced state management using
useState
anduseContext
, data persistence with a customuseLocalStorage
hook, and form validation with real-time feedback.I also improved the appβs user experience by implementing dynamic styling, disabled submit buttons for invalid input, and a clean, responsive UI. Separating concerns with modular components and utility functions helped keep the code organized and maintainable.
Overall, this project reinforced best practices like lifting state up, leveraging custom hooks, and writing reusable components β all essential skills for building scalable React applications. It also highlighted the importance of balancing functionality, user experience, and code quality in frontend development.
4. Whatβs Next:
Iβm excited to keep growing and sharing along the way! Hereβs whatβs coming up:
- Posting new blog updates every 3 days to share what Iβm learning and building.
- Diving deeper into Data Structures & Algorithms with Java β check out my ongoing DSA Journey Blog for detailed walkthroughs and solutions.
- Sharing regular progress and insights on X (Twitter) β feel free to follow me there and join the conversation!
Thanks for being part of this journey!
5. Conclusion:
In this blog of my web dev journey, we delved into some essential React hooks and practical project implementation techniques that are crucial for modern React development:
- Understanding the
useId
hook for generating unique and accessible IDs, improving form usability and accessibility. - Exploring the
useReducer
hook to manage complex state logic in a predictable and organized way. - Building a fully functional Expense Tracker App from scratch to apply these hooks along with other key React concepts like state management, context API, custom hooks, and form validation.
- Implementing data persistence with localStorage and ensuring seamless synchronization with React state using a custom
useLocalStorage
hook. - Leveraging component composition, conditional rendering, and dynamic styling to create a clean, responsive, and user-friendly interface.
By combining these hooks and techniques in a real-world project, I was able to reinforce my understanding and improve my confidence with Reactβs core features. Keep experimenting with these concepts and building projects to sharpen my skills and create efficient, maintainable React applications.
If you're on a similar journey, feel free to follow along or connect β weβre all in this together!
Subscribe to my newsletter
Read articles from Ritik Kumar directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Ritik Kumar
Ritik Kumar
π¨βπ» Aspiring Software Developer | MERN Stack Developer.π Documenting my journey in Full-Stack Development & DSA with Java.π Focused on writing clean code, building real-world projects, and continuous learning.