11 KiB
11 KiB
Sidebar Component Documentation
Architecture
src/
├── components/Sidebar/
│ ├── Sidebar.tsx # Main container component
│ ├── SidebarItem.tsx # Individual menu item
│ ├── SidebarSubmenu.tsx # Submenu component
│ └── SidebarUser.tsx # User info display
├── machines/
│ └── sidebarMachine.ts # State machine definition
└── hooks/machines/
│ └── useSidebarMachine.ts # React hook integration
└── contexts/
└── SidebarData.tsx # Navigation data provider
State Machine Explanation
Core Concepts
Events - Things that can happen:
TOGGLE_ITEM- User clicks a menu itemCLOSE_ALL- Close all submenusNAVIGATE- User navigates to a new route
Context - Data the machine remembers:
interface SidebarContext {
openItemId: string | null; // Which submenu is open (only one allowed)
activePath: string; // Current route for highlighting
}
States - Possible configurations:
collapsed- No submenus are openexpanded- One submenu is open
State Transitions
stateDiagram-v2
[*] --> collapsed
collapsed --> expanded : TOGGLE_ITEM(id)
expanded --> collapsed : TOGGLE_ITEM(same_id)
expanded --> expanded : TOGGLE_ITEM(different_id)
collapsed --> collapsed : CLOSE_ALL
expanded --> collapsed : CLOSE_ALL
Machine Definition
export const sidebarMachine = setup({
types: {
context: {} as SidebarContext,
events: {} as SidebarEvent,
},
}).createMachine({
id: 'sidebar',
initial: 'collapsed',
context: {
openItemId: null,
activePath: '/',
},
states: {
collapsed: {
on: {
TOGGLE_ITEM: {
target: 'expanded',
actions: assign({
openItemId: ({ event }) => event.itemId,
}),
},
},
},
expanded: {
on: {
TOGGLE_ITEM: [
{
// Same item clicked - close it
guard: ({ context, event }) => context.openItemId === event.itemId,
target: 'collapsed',
actions: assign({ openItemId: null }),
},
{
// Different item clicked - switch to it
target: 'expanded',
actions: assign({
openItemId: ({ event }) => event.itemId,
}),
},
],
},
},
},
});
React Hook Integration
useSidebarMachine Hook
The useSidebarMachine hook bridges XState with React components:
export const useSidebarMachine = () => {
// 1. Create machine actor
const [state, send] = useActor(sidebarMachine);
// 2. Sync with React Router
const location = useLocation();
useEffect(() => {
send({ type: 'NAVIGATE', path: location.pathname });
}, [location.pathname, send]);
// 3. Return easy-to-use API
return {
// State queries
hasOpenSubmenu: sidebarState.hasOpenSubmenu,
openItemId: sidebarState.openItemId,
currentState: state.value,
// Actions
toggleItem: (itemId: string) => send({ type: 'TOGGLE_ITEM', itemId }),
closeAll: () => send({ type: 'CLOSE_ALL' }),
isItemOpen: (itemId: string) => sidebarState.isItemOpen(itemId),
isItemActive: (itemPath?: string) => location.pathname === itemPath,
};
};
Hook Benefits
- Encapsulation: State machine logic is hidden from components
- Type Safety: TypeScript ensures correct usage
- Automatic Sync: Route changes update the machine automatically
- Simple API: Components only need simple functions
Data Loading (SidebarData)
Structure
interface SidebarItemType {
id: string;
name: string;
link?: string;
icon?: React.ComponentType;
submenu?: SidebarItemType[];
}
Implementation
const useSidebarData = () => {
const { t } = useLanguage();
return useMemo(() => [
{
id: '1',
name: t('nav.team'),
link: '/team-bereich',
icon: MdOutlineWorkOutline,
},
{
id: '2',
name: t('nav.dashboard'),
link: '/dashboard',
icon: LuTicket,
submenu: [ // Optional submenu
{ id: '2-1', name: 'Analytics', link: '/dashboard/analytics' },
{ id: '2-2', name: 'Reports', link: '/dashboard/reports' },
],
},
], [t]);
};
Key Features
- Internationalization: Uses language context for translations
- Memoization: Prevents unnecessary re-renders
- Flexible Structure: Supports nested submenus
- Icon Support: React component icons
Component Interactions
Sidebar (Main Container)
const Sidebar: React.FC<SidebarProps> = ({ data }) => {
const sidebarMachine = useSidebarMachine();
return (
<div className={styles.sidebarContainer}>
{/* Logo and User sections */}
<div className={styles.sidebar}>
{data.map(item => (
<SidebarItem
key={item.id}
item={item}
isOpen={sidebarMachine.isItemOpen(item.id)}
onToggle={() => sidebarMachine.toggleItem(item.id)}
isActive={sidebarMachine.isItemActive(item.link)}
/>
))}
</div>
</div>
);
};
Responsibilities:
- Initialize state machine
- Pass state and actions to children
- Handle layout and styling
SidebarItem (Individual Menu Item)
const SidebarItem: React.FC<SidebarItemProps> = ({
item,
isOpen,
onToggle,
isActive
}) => {
const hasSubItems = item.submenu && item.submenu.length > 0;
const toggleSubmenu = (e: React.MouseEvent) => {
if (hasSubItems) {
e.preventDefault();
onToggle(); // Call parent's toggle function
}
};
return (
<div className={styles.menu}>
<li className={`${isActive ? styles.active : ""}`}>
{hasSubItems ? (
<a href="#" onClick={toggleSubmenu}>
{item.name}
<IoIosArrowDown className={`${isOpen ? styles.rotated : ''}`} />
</a>
) : (
<Link to={item.link || "#"}>{item.name}</Link>
)}
</li>
{hasSubItems && <SidebarSubmenu item={item} isOpen={isOpen} />}
</div>
);
};
Key Changes from Original:
- ❌ Removed:
useStatefor local state - ✅ Added: Props from parent state machine
- ✅ Benefit: No independent state management
SidebarSubmenu (Nested Menu)
const SidebarSubmenu: React.FC<SidebarSubmenuProps> = ({ item, isOpen }) => {
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
>
{/* Submenu items */}
</motion.div>
)}
</AnimatePresence>
);
};
Responsibilities:
- Render submenu when
isOpenis true - Handle animations with Framer Motion
- Manage text overflow behavior
Data Flow
1. User clicks menu item
↓
2. SidebarItem calls onToggle()
↓
3. onToggle sends TOGGLE_ITEM event to machine
↓
4. Machine transitions states and updates context
↓
5. Hook returns new state
↓
6. Sidebar re-renders with new isOpen values
↓
7. SidebarSubmenu shows/hides based on isOpen
Adding New States
Example: Adding "Pinned" State
- Update Events:
export type SidebarEvent =
| { type: 'TOGGLE_ITEM'; itemId: string }
| { type: 'PIN_SIDEBAR' } // New event
| { type: 'UNPIN_SIDEBAR' } // New event
| { type: 'CLOSE_ALL' }
| { type: 'NAVIGATE'; path: string };
- Update Context:
export interface SidebarContext {
openItemId: string | null;
activePath: string;
isPinned: boolean; // New context property
}
- Add New States:
states: {
collapsed: {
on: {
PIN_SIDEBAR: 'pinnedCollapsed',
// ... existing transitions
},
},
expanded: {
on: {
PIN_SIDEBAR: 'pinnedExpanded',
// ... existing transitions
},
},
pinnedCollapsed: { // New state
on: {
UNPIN_SIDEBAR: 'collapsed',
TOGGLE_ITEM: 'pinnedExpanded',
},
},
pinnedExpanded: { // New state
on: {
UNPIN_SIDEBAR: 'expanded',
// ... similar to expanded
},
},
},
- Update Hook:
return {
// ... existing returns
isPinned: state.matches('pinnedCollapsed') || state.matches('pinnedExpanded'),
pinSidebar: () => send({ type: 'PIN_SIDEBAR' }),
unpinSidebar: () => send({ type: 'UNPIN_SIDEBAR' }),
};
Best Practices
State Machine Design
- Keep States Simple: Each state should represent a distinct UI mode
- Use Guards Wisely: For conditional transitions based on context
- Minimize Context: Only store data that affects behavior
- Clear Event Names: Use descriptive, action-oriented names
Component Architecture
- Single Source of Truth: State machine holds all navigation state
- Props Down: Pass state and actions as props
- Events Up: Send events to the machine, not direct state updates
- Separation of Concerns: Components handle UI, machine handles logic
Performance
- Memoize Data: Use
useMemofor sidebar data - Avoid Deep Objects: Keep context flat when possible
- Selective Subscriptions: Only subscribe to needed state slices
Debugging
Development Tools
- Debug Props: Hook returns
_debugStateand_debugSend - State Logging: Log state changes in development
- XState DevTools: Use
@xstate/inspectfor visual debugging
Common Issues
- Multiple Items Open: Check guard conditions in TOGGLE_ITEM
- State Not Updating: Ensure events are sent correctly
- Route Sync Issues: Verify NAVIGATE event is sent on route change
Testing
Unit Testing State Machine
import { sidebarMachine } from '../machines/sidebarMachine';
test('should open submenu when item toggled', () => {
const state = sidebarMachine.transition('collapsed', {
type: 'TOGGLE_ITEM',
itemId: 'menu-1',
});
expect(state.value).toBe('expanded');
expect(state.context.openItemId).toBe('menu-1');
});
Integration Testing
import { render, fireEvent } from '@testing-library/react';
import { SidebarWithData } from '../components/Sidebar';
test('should close submenu when same item clicked twice', () => {
const { getByText } = render(<SidebarWithData />);
const menuItem = getByText('Dashboard');
fireEvent.click(menuItem); // Open
fireEvent.click(menuItem); // Close
expect(/* submenu is closed */).toBeTruthy();
});
Migration Guide
From useState to State Machine
Before:
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(!isOpen);
After:
const { isItemOpen, toggleItem } = useSidebarMachine();
// Use: isItemOpen(itemId) and toggleItem(itemId)
Benefits of Migration
- Predictable State: No more impossible state combinations
- Centralized Logic: All sidebar behavior in one place
- Better Testing: State machine logic is easily testable
- Type Safety: TypeScript prevents invalid transitions
- Debugging: Clear state visualization and logging