fix expenses list performance + dom nesting

- memoize accordion cards and groupByMonth so toggling one
  expense re-renders O(1) cards instead of the whole list
- cache Intl.NumberFormat and date formatters per key (was
  ~2-3 instantiations per item per render)
- unmount collapsed accordion details via unmountOnExit to cut
  initial mount cost and input delay
- restructure Account row in ExpenseDetail so Chip is not nested
  inside <p> (fixes validateDOMNesting warning)
- avoid mutating expenses state array when sorting
This commit is contained in:
2026-08-17 15:22:37 +05:30
parent 9808a1f39b
commit d6856a538f
4 changed files with 128 additions and 90 deletions

View File

@@ -54,7 +54,7 @@ export default function Expense() {
const sorted = useMemo(
() =>
(items ?? []).sort(
[...(items ?? [])].sort(
(a, b) => new Date(b.occurred_at ?? 0).getTime() - new Date(a.occurred_at ?? 0).getTime(),
),
[items],

View File

@@ -33,17 +33,19 @@ export function ExpenseDetail({ item }: { item: ExpenseItem }) {
<Row
label="Account"
value={
<Typography variant="body2">
<Box sx={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 1 }}>
<Typography component="span" variant="body2">
{account ? `${account.name}${last4 ? ` (${last4})` : ""}` : "—"}
</Typography>
{account?.type && (
<Chip
size="small"
label={account.type.replace(/_/g, " ")}
variant="outlined"
sx={{ ml: 1.5, fontSize: 11, height: 20 }}
sx={{ fontSize: 11, height: 20 }}
/>
)}
</Typography>
</Box>
}
/>
<Row

View File

@@ -1,7 +1,6 @@
import React, { useState } from "react";
import React, { useCallback, useMemo, useState } from "react";
import {
Box,
Paper,
Typography,
Accordion,
AccordionSummary,
@@ -67,40 +66,26 @@ function EntityAvatar({ entity }: { entity: ExpenseItem["entity"] }) {
);
}
export function ExpenseList({ items }: { items: ExpenseItem[] }) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const groups = groupByMonth(items);
interface ExpenseCardProps {
item: ExpenseItem;
expanded: boolean;
currency: string;
onToggle: (id: string) => void;
}
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 4 }}>
{groups.map((group) => (
<Box key={group.key}>
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1.5, mb: 1.5 }}>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
{monthLabel(group.key)}
</Typography>
<Typography variant="caption" color="text.secondary">
{group.items.length} transaction{group.items.length === 1 ? "" : "s"}
</Typography>
<Box sx={{ flex: 1 }} />
<Typography variant="body2" fontWeight={600}>
{formatCurrency(group.total, group.currency)}
</Typography>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{group.items.map((item) => {
const ExpenseCard = React.memo(function ExpenseCard({ item, expanded, currency, onToggle }: ExpenseCardProps) {
const negative = isExpense(item);
const currency = item.account?.currency ?? group.currency;
const itemCurrency = item.account?.currency ?? currency;
return (
<Accordion
key={item.id}
disableGutters
expanded={expandedId === item.id}
onChange={(_, expanded) => setExpandedId(expanded ? item.id : null)}
expanded={expanded}
onChange={(_, isExpanded) => onToggle(isExpanded ? item.id : "")}
TransitionProps={{ unmountOnExit: true }}
sx={{
border: "1px solid",
borderColor: expandedId === item.id ? "primary.main" : "divider",
borderColor: expanded ? "primary.main" : "divider",
borderRadius: 2,
overflow: "hidden",
boxShadow: "none",
@@ -154,7 +139,7 @@ export function ExpenseList({ items }: { items: ExpenseItem[] }) {
flexShrink: 0,
}}
>
{formatCurrency(item.amount, currency)}
{formatCurrency(item.amount, itemCurrency)}
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
@@ -162,7 +147,42 @@ export function ExpenseList({ items }: { items: ExpenseItem[] }) {
</AccordionDetails>
</Accordion>
);
})}
});
export function ExpenseList({ items }: { items: ExpenseItem[] }) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const groups = useMemo(() => groupByMonth(items), [items]);
const handleToggle = useCallback((id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
}, []);
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 4 }}>
{groups.map((group) => (
<Box key={group.key}>
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1.5, mb: 1.5 }}>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
{monthLabel(group.key)}
</Typography>
<Typography variant="caption" color="text.secondary">
{group.items.length} transaction{group.items.length === 1 ? "" : "s"}
</Typography>
<Box sx={{ flex: 1 }} />
<Typography variant="body2" fontWeight={600}>
{formatCurrency(group.total, group.currency)}
</Typography>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{group.items.map((item) => (
<ExpenseCard
key={item.id}
item={item}
expanded={expandedId === item.id}
currency={group.currency}
onToggle={handleToggle}
/>
))}
</Box>
</Box>
))}

View File

@@ -13,37 +13,53 @@ export function isExpense(item: ExpenseItem): boolean {
return (item.amount ?? 0) < 0;
}
const CURRENCIES = ["INR", "USD", "EUR", "GBP", "AED", "SGD"];
const _currencyFormatters = new Map<string, Intl.NumberFormat>();
export function formatCurrency(amount: number, currency?: string): string {
const code = currency && ["INR", "USD", "EUR", "GBP", "AED", "SGD"].includes(currency) ? currency : "INR";
try {
return new Intl.NumberFormat("en-IN", {
const code = currency && CURRENCIES.includes(currency) ? currency : "INR";
let formatter = _currencyFormatters.get(code);
if (!formatter) {
formatter = new Intl.NumberFormat("en-IN", {
style: "currency",
currency: code,
maximumFractionDigits: 2,
}).format(amount);
} catch {
return `${amount.toFixed(2)}`;
});
_currencyFormatters.set(code, formatter);
}
return formatter.format(amount);
}
const _dateCache = new Map<string, string>();
export function formatDate(iso?: string): string {
if (!iso) return "—";
const cached = _dateCache.get(iso);
if (cached) return cached;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" });
const out = d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" });
_dateCache.set(iso, out);
return out;
}
const _dateTimeCache = new Map<string, string>();
export function formatDateTime(iso?: string): string {
if (!iso) return "—";
const cached = _dateTimeCache.get(iso);
if (cached) return cached;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString("en-IN", {
const out = d.toLocaleString("en-IN", {
day: "numeric",
month: "short",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
_dateTimeCache.set(iso, out);
return out;
}
export function monthKey(iso?: string): string {