perf: optimize menu and navigation response time

- Add React.memo + useMemo/useCallback to EPD sidebar, header, and tabs
- Prevent unnecessary re-renders on pathname changes
- Fix context reset causing UI flashing during navigation
- Target: <250ms menu response (was ~260ms)

Files: epd-sidebar, epd-header, patient-context, intake-tabs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
colinislit
2025-11-25 14:05:58 +01:00
parent 7033329e0f
commit f7e33d0335
4 changed files with 245 additions and 163 deletions

View File

@@ -1,5 +1,5 @@
"use client";
import React from 'react';
import React, { useMemo, useCallback, memo } from 'react';
import { Search, MoreVertical, Mic } from 'lucide-react';
import { useRouter, usePathname } from 'next/navigation';
import { usePatientContext } from './patient-context';
@@ -37,12 +37,71 @@ function StatusBadge({ status }: { status?: string }) {
);
}
export function EPDHeader({ className = "" }: EPDHeaderProps) {
export const EPDHeader = memo(function EPDHeader({ className = "" }: EPDHeaderProps) {
const { patient } = usePatientContext();
const router = useRouter();
const pathname = usePathname();
const handleNewReportClick = () => {
// Memoize patient display data to prevent recalculation on every render
const patientDisplay = useMemo(() => {
if (!patient) return null;
// Extract patient data
const name = patient.name?.[0];
const fullName = name
? [
...(name.prefix || []),
...(name.given || []),
name.family,
]
.filter(Boolean)
.join(' ')
: '';
// Extract status from extension
const statusExtension = (patient as any)?.extension?.find(
(ext: any) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/episode-status'
);
const status = statusExtension?.valueCode;
// Extract birth date
const birthDate = patient.birthDate
? new Date(patient.birthDate).toLocaleDateString('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
: null;
// Extract BSN from identifiers
const bsnIdentifier = patient.identifier?.find(
(id: any) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn' ||
id.system?.includes('bsn') ||
id.type?.coding?.[0]?.code === 'BSN'
);
const bsn = bsnIdentifier?.value;
// Extract last modified
const lastModified = patient.meta?.lastUpdated
? new Date(patient.meta.lastUpdated).toLocaleString('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: null;
// Check if John Doe
const isJohnDoe = (patient as any)?.extension?.find(
(ext: any) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/john-doe'
)?.valueBoolean;
return { fullName, status, birthDate, bsn, lastModified, isJohnDoe };
}, [patient]);
// Stabilize event handler with useCallback
const handleNewReportClick = useCallback(() => {
if (!patient?.id) return;
const rapportagePath = `/epd/patients/${patient.id}/rapportage`;
const onRapportagePage = pathname?.startsWith(rapportagePath);
@@ -58,58 +117,10 @@ export function EPDHeader({ className = "" }: EPDHeaderProps) {
}
router.push(`${rapportagePath}#rapportage-composer`);
};
}, [patient?.id, pathname, router]);
// Extract patient data
const name = patient?.name?.[0];
const fullName = name
? [
...(name.prefix || []),
...(name.given || []),
name.family,
]
.filter(Boolean)
.join(' ')
: '';
// Extract status from extension
const statusExtension = (patient as any)?.extension?.find(
(ext: any) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/episode-status'
);
const status = statusExtension?.valueCode;
// Extract birth date
const birthDate = patient?.birthDate
? new Date(patient.birthDate).toLocaleDateString('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
: null;
// Extract BSN from identifiers
const bsnIdentifier = patient?.identifier?.find(
(id: any) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn' ||
id.system?.includes('bsn') ||
id.type?.coding?.[0]?.code === 'BSN'
);
const bsn = bsnIdentifier?.value;
// Extract last modified
const lastModified = patient?.meta?.lastUpdated
? new Date(patient.meta.lastUpdated).toLocaleString('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: null;
// Check if John Doe
const isJohnDoe = (patient as any)?.extension?.find(
(ext: any) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/john-doe'
)?.valueBoolean;
// Destructure memoized values for cleaner JSX
const { fullName, status, birthDate, bsn, lastModified, isJohnDoe } = patientDisplay || {};
return (
<header className={`h-[60px] bg-white border-b border-slate-200 flex items-center px-6 ${className}`}>
@@ -179,4 +190,4 @@ export function EPDHeader({ className = "" }: EPDHeaderProps) {
</div>
</header>
);
}
});

View File

@@ -1,7 +1,8 @@
"use client";
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo, useCallback, memo } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import {
Users,
Settings,
@@ -53,22 +54,119 @@ const level2NavigationItems: NavigationItem[] = [
{ id: "rapportage", name: "Rapportage", icon: FileBarChart, href: "/rapportage" },
];
// Memoized sidebar item component to prevent unnecessary re-renders
interface SidebarItemProps {
item: NavigationItem;
isActive: boolean;
isCollapsed: boolean;
onClick: () => void;
}
const SidebarItem = memo(function SidebarItem({ item, isActive, isCollapsed, onClick }: SidebarItemProps) {
const Icon = item.icon;
return (
<li>
<Link
href={item.href}
onClick={onClick}
className={cn(
"w-full flex items-center space-x-2.5 px-3 py-2.5 rounded-md text-left transition-all duration-200 group",
isActive
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900",
isCollapsed && "justify-center px-2"
)}
title={isCollapsed ? item.name : undefined}
>
<div className="flex items-center justify-center min-w-[20px]">
<Icon
className={cn(
"h-5 w-5 flex-shrink-0",
isActive
? "text-slate-700"
: "text-slate-500 group-hover:text-slate-700"
)}
/>
</div>
{!isCollapsed && (
<div className="flex items-center justify-between w-full">
<span className={cn("text-sm", isActive ? "font-medium" : "font-normal")}>
{item.name}
</span>
{item.badge && (
<span className={cn(
"px-2 py-0.5 text-xs font-medium rounded-full",
isActive
? "bg-slate-200 text-slate-700"
: "bg-slate-100 text-slate-600"
)}>
{item.badge}
</span>
)}
</div>
)}
{/* Tooltip for collapsed state */}
{isCollapsed && (
<div className="absolute left-full ml-2 px-2 py-1 bg-slate-800 text-white text-xs rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 whitespace-nowrap z-50">
{item.name}
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 -translate-x-1 w-1.5 h-1.5 bg-slate-800 rotate-45" />
</div>
)}
</Link>
</li>
);
}, (prev, next) => {
// Custom comparison - only re-render if these props change
return prev.item.href === next.item.href &&
prev.isActive === next.isActive &&
prev.isCollapsed === next.isCollapsed;
});
export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarProps) {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(false);
// Context detection: Level 2 if URL contains /patients/[id]
const isPatientContext = pathname.match(/\/epd\/patients\/[^\/]+/);
const isPatientContext = pathname?.match(/\/epd\/patients\/[^\/]+/);
const patientId = isPatientContext ? pathname.split('/')[3] : null;
// Determine which navigation items to show
const navigationItems = isPatientContext
? level2NavigationItems.map(item => ({
// Memoize navigation items to prevent recreation on every render
const navigationItems = useMemo(() => {
if (isPatientContext) {
return level2NavigationItems.map(item => ({
...item,
href: `/epd/patients/${patientId}${item.href}`
}))
: level1NavigationItems;
}));
}
return level1NavigationItems;
}, [isPatientContext, patientId]);
// Stabilize event handlers with useCallback
const toggleSidebar = useCallback(() => {
setIsOpen(prev => !prev);
}, []);
const toggleCollapse = useCallback(() => {
setIsCollapsed(prev => !prev);
}, []);
const handleItemClick = useCallback(() => {
if (window.innerWidth < 768) {
setIsOpen(false);
}
}, []);
// Memoize isActive check function
const getIsActive = useCallback((item: NavigationItem): boolean => {
if (item.id === 'dashboard') {
return pathname === item.href;
}
return pathname === item.href || Boolean(item.href && pathname?.startsWith(item.href + '/'));
}, [pathname]);
// Auto-open sidebar on desktop
useEffect(() => {
@@ -85,15 +183,6 @@ export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarPr
return () => window.removeEventListener('resize', handleResize);
}, []);
const toggleSidebar = () => setIsOpen(!isOpen);
const toggleCollapse = () => setIsCollapsed(!isCollapsed);
const handleItemClick = () => {
if (window.innerWidth < 768) {
setIsOpen(false);
}
};
// Get user initials
const userInitials = userName
? userName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
@@ -182,71 +271,15 @@ export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarPr
)}
<ul className="space-y-1">
{navigationItems.map((item) => {
const Icon = item.icon;
// For Dashboard (empty suffix), only match exact path
// For others, match exact or any sub-route
const isActive = item.id === 'dashboard'
? pathname === item.href
: pathname === item.href || (item.href && pathname.startsWith(item.href + '/'));
return (
<li key={item.id}>
<Link
href={item.href}
onClick={handleItemClick}
className={`
w-full flex items-center space-x-2.5 px-3 py-2.5 rounded-md text-left transition-all duration-200 group
${isActive
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
}
${isCollapsed ? "justify-center px-2" : ""}
`}
title={isCollapsed ? item.name : undefined}
>
<div className="flex items-center justify-center min-w-[20px]">
<Icon
className={`
h-5 w-5 flex-shrink-0
${isActive
? "text-slate-700"
: "text-slate-500 group-hover:text-slate-700"
}
`}
/>
</div>
{!isCollapsed && (
<div className="flex items-center justify-between w-full">
<span className={`text-sm ${isActive ? "font-medium" : "font-normal"}`}>
{item.name}
</span>
{item.badge && (
<span className={`
px-2 py-0.5 text-xs font-medium rounded-full
${isActive
? "bg-slate-200 text-slate-700"
: "bg-slate-100 text-slate-600"
}
`}>
{item.badge}
</span>
)}
</div>
)}
{/* Tooltip for collapsed state */}
{isCollapsed && (
<div className="absolute left-full ml-2 px-2 py-1 bg-slate-800 text-white text-xs rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 whitespace-nowrap z-50">
{item.name}
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 -translate-x-1 w-1.5 h-1.5 bg-slate-800 rotate-45" />
</div>
)}
</Link>
</li>
);
})}
{navigationItems.map((item) => (
<SidebarItem
key={item.id}
item={item}
isActive={getIsActive(item)}
isCollapsed={isCollapsed}
onClick={handleItemClick}
/>
))}
</ul>
</nav>

View File

@@ -29,10 +29,15 @@ export function PatientProvider({ children }: { children: React.ReactNode }) {
// Hook to inject patient data into context from nested layouts
export function useSetPatient(patient: FHIRPatient | null) {
const { setPatient } = usePatientContext();
const { patient: currentPatient, setPatient } = usePatientContext();
useEffect(() => {
setPatient(patient);
return () => setPatient(null); // Cleanup when unmounting
}, [patient, setPatient]);
// Only update if patient ID changed - prevents flashing during navigation
if (patient?.id !== currentPatient?.id) {
setPatient(patient);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally only depend on ID, not full object
}, [patient?.id, currentPatient?.id, setPatient]);
// No cleanup - keep patient in context during navigation to prevent flashing
}

View File

@@ -1,5 +1,6 @@
'use client';
import { useMemo, useCallback, memo } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
@@ -9,11 +10,48 @@ interface IntakeTabsProps {
intakeId: string;
}
interface Tab {
name: string;
href: string;
exact?: boolean;
}
// Memoized tab item component to prevent unnecessary re-renders
interface TabItemProps {
tab: Tab;
isActive: boolean;
}
const TabItem = memo(function TabItem({ tab, isActive }: TabItemProps) {
return (
<Link
href={tab.href}
className={cn(
'whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium transition-colors',
isActive
? 'border-teal-500 text-teal-600'
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
)}
>
{tab.name}
</Link>
);
}, (prev, next) => {
// Only re-render if href or active state changes
return prev.tab.href === next.tab.href && prev.isActive === next.isActive;
});
export function IntakeTabs({ patientId, intakeId }: IntakeTabsProps) {
const pathname = usePathname();
const baseUrl = `/epd/patients/${patientId}/intakes/${intakeId}`;
const tabs = [
// Memoize base URL to prevent recalculation
const baseUrl = useMemo(
() => `/epd/patients/${patientId}/intakes/${intakeId}`,
[patientId, intakeId]
);
// Memoize tabs array to prevent recreation on every render
const tabs = useMemo<Tab[]>(() => [
{ name: 'Algemeen', href: baseUrl, exact: true },
{ name: 'Contactmomenten', href: `${baseUrl}/contacts` },
{ name: 'Kindcheck', href: `${baseUrl}/kindcheck` },
@@ -23,31 +61,26 @@ export function IntakeTabs({ patientId, intakeId }: IntakeTabsProps) {
{ name: 'ROM', href: `${baseUrl}/rom` },
{ name: 'Diagnose', href: `${baseUrl}/diagnosis` },
{ name: 'Behandeladvies', href: `${baseUrl}/behandeladvies` },
];
], [baseUrl]);
// Memoize isActive check function
const getIsActive = useCallback((tab: Tab) => {
if (tab.exact) {
return pathname === tab.href;
}
return pathname?.startsWith(tab.href);
}, [pathname]);
return (
<div className="border-b border-slate-200 bg-white px-6">
<nav className="-mb-px flex space-x-6 overflow-x-auto">
{tabs.map((tab) => {
const isActive = tab.exact
? pathname === tab.href
: pathname.startsWith(tab.href);
return (
<Link
key={tab.name}
href={tab.href}
className={cn(
'whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium transition-colors',
isActive
? 'border-teal-500 text-teal-600'
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
)}
>
{tab.name}
</Link>
);
})}
{tabs.map((tab) => (
<TabItem
key={tab.name}
tab={tab}
isActive={getIsActive(tab)}
/>
))}
</nav>
</div>
);