Reusable Components
Shared components are UI and functional elements used across multiple sections of the application.
This guide outlines how to create and manage these components to ensure a unified design system and efficient code reuse.
Quick reference
-
Screens (routing-level UI)
src/screens/Auth/**
src/screens/Public/**
src/screens/AdminPortal/**
src/screens/UserPortal/** -
Admin UI:
src/components/AdminPortal/**
src/types/AdminPortal/** -
User UI
src/components/UserPortal/**
src/types/UserPortal/** -
Shared UI
src/shared-components/**
src/types/shared-components/** -
Props definitions
- interface.ts only (no inline interfaces)
-
Exports
- PascalCase, name matches folder/file
-
Tests
- colocated .spec.tsx
- target 100% test code coverage
-
i18n
- all user-visible text uses keys
-
TSDoc
- brief headers on components and interfaces
Component Architecture
It's important to understand structure and behavior of shared components before creating or refactoring them.
Folder Layout
Use the following path structure for shared components.
src/
components/
AdminPortal/ # Admin-only UI and hooks
UserTableRow/
UserTableRow.tsx
UserTableRow.spec.tsx
hooks/
useCursorPagination.ts
useCursorPagination.spec.ts
UserPortal/ # User-only UI and hooks
...
shared-components/ # Shared UI (kebab-case base, PascalCase children)
ProfileAvatarDisplay/
ProfileAvatarDisplay.tsx
ProfileAvatarDisplay.spec.tsx
BaseModal/
BaseModal.tsx
BaseModal.spec.tsx
EmptyState/
EmptyState.tsx
EmptyState.spec.tsx
LoadingState/
LoadingState.tsx
LoadingState.spec.tsx
types/
AdminPortal/ # Admin-only types
UserTableRow/
interface.ts
Pagination/
interface.ts
UserPortal/ # User-only types (as needed)
...
shared-components/ # Shared types mirror components
ProfileAvatarDisplay/
interface.ts
BaseModal/
interface.ts
EmptyState/
interface.ts
LoadingState/
interface.ts
screens/
AdminPortal/ # Admin-only screens
Users/
Users.tsx
Users.spec.tsx
UserPortal/ # User-only screens
Campaigns/
Canpaigns.stx
Campaigns.spec.tsx
Auth/ # Auth-only screens
LoginPage/
LoginPage.tsx
LoginPage.spec.tsx
Public/ # Unauthenticated screens
Invitation/
Invitation.tsx
Invitation.spec.tsx
Rationale
There are many reasons for this structure:
-
Clear ownership: Admin vs User portal code is easy to find.
-
Reuse with intent: Truly shared UI lives in one place.
-
Safer changes: Portal-specific changes can’t silently affect the other portal.
-
Faster onboarding and reviews: Predictable paths and conventions.
Placement Rules
-
Admin-only UI
src/components/AdminPortal/** (types in src/types/AdminPortal/**). -
User-only UI
src/components/UserPortal/** (types in src/types/UserPortal/**). -
Shared UI used by both portals
src/shared-components/** (types in src/types/shared-components/**). -
Portal-specific hooks live under that portal (e.g., AdminPortal/hooks). Promote to shared only when used by both portals.
Screen Placement Rules
- Authentication-related screens
src/screens/Auth/**
Examples: Login, ForgotPassword, ResetPassword - Admin-only screens
src/screens/AdminPortal/**
Examples: Users, CommunityProfile, Notification - User-only screens
src/screens/UserPortal/**
Examples: Campaigns, Chat, Donate - Public, unauthenticated screens
src/screens/Public/**
Examples: Invitation acceptance, PageNotFound, public info pages
Naming Conventions
- Use PascalCase for component and folder names (e.g.,
OrgCard,Button).- The
types/shared-componentsfolder is the sole exception
- The
- The component files should be PascalCase and the name should match the component (e.g.,
OrgCard.tsx,Button.tsx). - Tests should be named
Component.spec.tsx. - Mock files should follow
ComponentMock.ts. - Type interface should be defined in corresponding interface / type file.
-
For example:
src/types/\<Portal or shared-components\>/\<Component\>/interface.ts(single source of truth for props; no inline prop interfaces).src/
│
├── types/
└── AdminPortal
├── Component
└── interface.ts
└── type.ts
-
Imports (examples)
// shared
import { ProfileAvatarDisplay } from '/shared-components/ProfileAvatarDisplay/ProfileAvatarDisplay';
// admin
import { UserTableRow } from 'components/AdminPortal/UserTableRow/UserTableRow';
Wrapper Components and Restricted Imports
Some shared components are wrappers around third-party UI libraries. To enforce consistent usage, direct imports from those libraries are restricted by ESLint, and only the wrapper implementations may import them.
What is restricted (and what to use instead)
@mui/x-data-gridand@mui/x-data-grid-pro-> useDataGridWrapperreact-bootstrapSpinner-> useLoadingStatereact-bootstrapModal-> useBaseModalreact-bootstrapButton-> use the sharedButtonwrapper@mui/x-date-pickers-> useDateRangePicker,DatePicker, orTimePickerreact-toastify-> useNotificationToast
These restrictions are enforced by no-restricted-imports in eslint.config.js.
Where direct imports are allowed
Direct imports are only allowed inside the wrapper component implementations. The ESLint config defines a central registry of restricted imports and then allows specific IDs per folder.
const restrictedImports = [
{ id: 'mui-data-grid', name: '@mui/x-data-grid', message: '...' },
{ id: 'mui-data-grid-pro', name: '@mui/x-data-grid-pro', message: '...' },
{
id: 'rb-spinner',
name: 'react-bootstrap',
importNames: ['Spinner'],
message: '...',
},
{
id: 'rb-modal',
name: 'react-bootstrap',
importNames: ['Modal'],
message: '...',
},
{ id: 'mui-date-pickers', name: '@mui/x-date-pickers', message: '...' },
];
const restrictImportsExcept = (allowedIds = []) => ({
'no-restricted-imports': [
'error',
{
paths: restrictedImports
.filter(({ id }) => !allowedIds.includes(id))
.map(({ id, ...rule }) => rule),
},
],
});
Allowed IDs by folder:
- DataGridWrapper:
mui-data-grid,mui-data-grid-prosrc/shared-components/DataGridWrapper/**src/types/DataGridWrapper/**
- LoadingState/Loader:
rb-spinnersrc/shared-components/LoadingState/**src/types/shared-components/LoadingState/**src/components/Loader/**
- BaseModal:
rb-modalsrc/shared-components/BaseModal/**src/types/shared-components/BaseModal/**
- Button wrapper:
rb-button,rb-button-pathsrc/shared-components/Button/**src/types/shared-components/Button/**
- Date pickers:
mui-date-pickerssrc/shared-components/DateRangePicker/**src/types/shared-components/DateRangePicker/**src/shared-components/DatePicker/**src/shared-components/TimePicker/**src/index.tsx
- NotificationToast:
react-toastifysrc/components/NotificationToast/**src/types/NotificationToast/**
Adding a new restricted import or wrapper
- Add a new entry to
restrictedImportsineslint.config.jswith a uniqueid,name, andmessage. - Allow that ID in the wrapper folder override using
restrictImportsExcept([...]). - Update this document to list the new restriction and allowed folder(s).
Troubleshooting
If you see an error like:
'@mui/x-data-grid' import is restricted from being used. ...
it means you are importing a restricted library outside the allowed wrapper folders. Switch to the shared wrapper component or update the ESLint exception only if you are building the wrapper itself.
i18n
- All screen-visible text must use translation keys. No hardcoded strings.
- Provide alt text and aria labels via i18n where user-facing.
Example:
<BaseModal
title={t('members.remove_title')}
confirmLabel={t('common.confirm')}
cancelLabel={t('common.cancel')}
/>
TSDoc Documentation
Add a brief TSDoc header to:
- Each component: what it does, key behaviors, important a11y notes.
- Each interface.ts: props with short descriptions and defaults.
Example:
/**
* ProfileAvatarDisplay renders a user’s image or initials fallback.
* - Sizes: small | medium | large | custom
* - A11y: always sets meaningful alt; handles broken image fallback
*/
Accessibility (a11y) essentials
- Images: meaningful alt; fallback to initials when URL is empty/invalid.
- Modals: role="dialog", aria-modal, labelled by title; focus trap; Escape to close.
- Buttons/links: accessible names; keyboard operable.
Shared Button wrapper
- Path:
src/shared-components/Button/Button.tsx(barrel atsrc/shared-components/Button/index.ts). - Import:
import { Button } from 'shared-components/Button';. - Features: wraps
react-bootstrap/Button, supports common variants (primary/secondary/success/ danger/warning/info/dark/light/outline-*; aliasesoutlined/outlinemap tooutline-primary), sizessm/md/lg/xl, full-width layout, loading state (isLoading,loadingText), optional icons withiconPosition, and forwards all other bootstrap button props. - Lint: direct imports from
react-bootstraporreact-bootstrap/Buttonare restricted; use the shared Button wrapper instead (the wrapper folder is exempted to build it).
Understanding Components Reuse
Learn how shared components are integrated and reused across different areas of the application.
Props Driven Design
Props-driven design focuses on building components that adapt their behavior, appearance, and content based on the props rather than hardcoded values. This approach increases flexibility and reusability, allowing the same component to serve multiple purposes across the application. By passing data, event handlers, and configuration options through props.
import React from 'react';
import styles from 'style/app-fixed.module.css';
interface ButtonProps {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary';
}
const Button: React.FC<ButtonProps> = ({
label,
onClick,
variant = 'primary',
}) => {
const variantStyle =
variant === 'primary' ? styles.primaryButton : styles.secondaryButton;
return (
<button
onClick={onClick}
className={`${styles.buttonStyle} ${variantStyle}`}
>
{label}
</button>
);
};
export default Button;
Handling Role Based Differences
In some cases, a shared component needs to behave differently depending on the user's role. Instead of creating separate components for each role, you can handle variations through props. This ensures a single, maintainable source while keeping the UI consistent.
For example, the OrgCard component below adjusts its rendering based on the role and isMember props:
- If role is
admin, it shows the Manage button instead and displays admin-specific details. - If user is
member, it shows a visit button. - If user is
Not a member, it shows a join button.
import React from 'react';
import styles from 'style/app-fixed.module.css';
import InterfaceOrgCardProps from 'src/types/Organization/interface.ts';
const OrgCard: React.FC<InterfaceOrgCardProps> = ({
name,
address,
membersCount,
adminsCount,
role,
isMember,
}) => {
return (
<div className={styles.orgCard}>
<div>
<h3 className="font-semibold text-lg">{name}</h3>
{role === 'admin' && adminsCount !== undefined && (
<p className="text-sm text-gray-600">Admins: {adminsCount}</p>
)}
<p className="text-sm text-gray-600">Members: {membersCount}</p>
<p className="text-sm text-gray-500">{address}</p>
</div>
<button onClick={handleClick} className={styles.orgCardButton}>
{role === 'admin' ? 'Manage' : isMember ? 'Visit' : 'Join'}
</button>
</div>
);
};
export default OrgCard;
Existing Shared Components
Below are some commonly used shared components available in the codebase.
EmptyState
EmptyState is a reusable shared component for displaying consistent empty, no-data, or no-result states across the application.
It replaces legacy .notFound CSS-based implementations and standardizes empty UI patterns.
Component Location
src/shared-components/EmptyState/
Use cases:
- No search results
- Empty lists or tables
- No organizations / users / events
- First-time onboarding states
Key features:
- Optional icon, description, and action button
- Built-in accessibility (
role="status",aria-label) - i18n-ready (supports translation keys and plain strings)
- Fully tested with 100% coverage
Example usage:
import EmptyState from 'src/shared-components/EmptyState/EmptyState';
<EmptyState
message="noResults"
description="tryAdjustingFilters"
icon="person_off"
action={{
label: 'createNew',
onClick: handleCreate,
variant: 'primary',
}}
/>;
When to Use EmptyState
Use EmptyState for:
- Empty lists or tables
- No search results
- No organizations, users, or events
- First-time or onboarding states
- Filtered results returning no data
Do not use EmptyState for:
- 404 or route-level errors (use NotFound instead)
Component API
Import
import EmptyState from 'src/shared-components/EmptyState/EmptyState';
Props
| Prop | Type | Required | Description |
|---|---|---|---|
message | string | Yes | Primary message (i18n key or plain string) |
description | string | No | Secondary supporting text |
icon | string | ReactNode | No | Icon name or custom icon component |
action | object | No | Optional action button configuration |
className | string | No | Custom CSS class |
dataTestId | string | No | Test identifier |
Action Prop Shape
interface EmptyStateAction {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'outlined';
}
Usage Example
1. Simple Empty State:
<EmptyState message="noDataFound" />
2. Empty State With Icon:
<EmptyState
icon="groups"
message="noOrganizationsFound"
description="createOrganizationToGetStarted"
/>
3. Search Empty State:
<EmptyState
icon="search"
message="noResultsFound"
description={tCommon('noResultsFoundFor', {
query: searchTerm,
})}
/>
4. Empty State With Action Button:
<EmptyState
icon="person_off"
message="noUsersFound"
description="inviteUsersToGetStarted"
action={{
label: 'inviteUser',
onClick: handleInvite,
variant: 'primary',
}}
/>
ErrorBoundaryWrapper
ErrorBoundaryWrapper is a error boundary component that catches JavaScript errors in child components, logs them, and displays a fallback UI instead of crashing the entire application.
Use cases:
- Wrapping critical components that might throw render errors
- Protecting modals, forms, and complex UI sections
- Providing graceful error recovery for users
- Integrating with error tracking services (e.g., Sentry, LogRocket)
Key features:
- Catches render errors that try-catch cannot handle
- Provides default and custom fallback UI options
- Integrates with toast notification system
- Supports error recovery via reset mechanism
- Allows error logging/tracking integration
- Fully accessible (keyboard navigation, screen reader support)
- Fully tested with 100% coverage
Example usage:
import { ErrorBoundaryWrapper } from 'src/shared-components/ErrorBoundaryWrapper';
// Basic usage with default fallback
<ErrorBoundaryWrapper>
<YourComponent />
</ErrorBoundaryWrapper>
// With custom error message and logging
<ErrorBoundaryWrapper
errorMessage={t('errors.defaultErrorMessage')}
onError={(error, info) => logToService(error, info)}
onReset={() => navigate('/dashboard')}
>
<ComplexModal />
</ErrorBoundaryWrapper>
// Default fallback with custom i18n strings
<ErrorBoundaryWrapper
fallbackTitle={t('errors.title')}
fallbackErrorMessage={t('errors.defaultErrorMessage')}
resetButtonText={t('errors.resetButton')}
resetButtonAriaLabel={t('errors.resetButtonAriaLabel')}
>
<ComplexModal />
</ErrorBoundaryWrapper>
// With custom fallback component
const CustomErrorFallback = ({ error, onReset }) => (
<div>
<h2>Custom Error UI</h2>
<p>{error?.message}</p>
<button onClick={onReset}>Retry</button>
</div>
);
<ErrorBoundaryWrapper fallbackComponent={CustomErrorFallback}>
<Modal />
</ErrorBoundaryWrapper>
// With custom JSX fallback
<ErrorBoundaryWrapper
fallback={<div>Something went wrong. Please refresh.</div>}
>
<ComplexForm />
</ErrorBoundaryWrapper>
// Disable toast notifications
<ErrorBoundaryWrapper showToast={false}>
<Component />
</ErrorBoundaryWrapper>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
children | ReactNode | Yes | Child components to wrap with error boundary |
fallback | ReactNode | No | Custom JSX fallback UI |
fallbackComponent | React.ComponentType<InterfaceErrorFallbackProps> | No | Custom fallback component that receives error and onReset props |
errorMessage | string | No | Custom error message for toast notification |
showToast | boolean | No | Whether to show toast notification (default: true) |
onError | function | No | Callback invoked when error is caught |
onReset | function | No | Callback invoked when user clicks reset button |
fallbackTitle | string | No | Custom error message for default UI |
fallbackErrorMessage | string | No | Custom error message for default UI |
resetButtonText | string | No | Custom error message for default UI |
resetButtonAriaLabel | string | No | Custom error message for default UI |
Accessibility:
- Default fallback includes
role="alert"andaria-live="assertive" - Reset button is keyboard accessible (Enter and Space keys)
- Screen reader friendly error messages
- High contrast and dark mode support
Relationship with Loading States
- Use LoadingState while data is being fetched
- Render EmptyState only after loading completes
- Never show EmptyState during an active loading state
Migration Guidance
- Legacy
.notFoundCSS patterns are deprecated - All new empty-state implementations must use EmptyState
- Existing screens should be migrated incrementally
Creating Shared Components
This section provides guidance on our shared components policy.
When Not to Create a Shared Component
Avoid placing a component in the shared folder if:
- It's used in only one screen or context.
- The design is too unique to be reused elsewhere.
Instead, keep such components in their specific module.
When to Create a Shared Component
Create a shared component if:
- It's being used at multiple places.
- Only props differ, not the core layout or logic.
- It represents a common design pattern (like a card, button, etc.).
- It's likely to be used in the future.
Defining a Strict Props Structure
Each shared component must define a clear, typed interface for its props, placed in a corresponding interface.ts file.
Why Strict Typing Matters
Strict typing is crucial when building reusable components, as these components are meant to be used across different modules, teams, and contexts. By defining clear and specific TypeScript interfaces for props, you ensure that each component communicates exactly what is expected and how it should behave, eliminating ambiguity for other developers who reuse it.
Type props act as a form of self-documentation, providing instant clarity through autocompletion and inline hints when a component is imported elsewhere. This helps prevent misuse, such as passing unsupported data or missing required props, which can lead to inconsistent UI behavior.
It also ensures that any changes in shared components are propagated safely, with TypeScript catching any incompatible usage at build time instead of letting them cause runtime errors. Ultimately, strict typing keeps your reusable code reliable, maintainable, and predictable, ensuring that they behave consistently wherever they are used in the project.
Key Rules
- Always use TypeScript interfaces, avoid using
any. - Avoid passing entire objects, instead destructure and pass only required fields.
- Each prop should serve a single purpose (data, action, or style control).
- Use clear, descriptive prop names like
isMember,variant, orroleinstead of generic terms likeflagortype.
Examples
-
Role based props: When a component behaves differently for user types (admin / user), define a
roleprop to handle that difference cleanly.interface InterfaceOrgCardProps {
name: string;
address: string;
membersCount: number;
adminsCount?: number;
role: 'admin' | 'user';
isMember?: boolean;
} -
Variant based props: For components with multiple design or behavior styles (like buttons or cards), define a
variantprop.interface ButtonProps {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'outlined';
}- The
variantprop helps the component adapt its appearance dynamically:primary→ For main action on a page (example: Save, Submit).secondary→ For supporting action (example: Cancel, Edit).outlined→ For neutral or optional actions (example: Learn more, Back).
- The
Styling Guidelines
- Use existing global or shared CSS modules whenever possible to maintain consistency.
- Avoid inline styles unles necessary for dynamic cases.
- When defining styles, prefer semantic class names (e.g.,
buttonPrimary,cardHeader).
Testing Shared Component
- Each shared component must include a corresponding test file (
Component.spec.tsx) - Refer to the testing page of the documentation website
Document Your Code
Use TSDoc comments to document functions, classes, and interfaces within reusable components. Clearly describe the component's purpose, its props and any return value. This practice not only improves readability but also helps maintain consistency across shared components, especially when they are used by multiple developers or teams. Well-documented props and behavior makes it easier for others to quickly understand how to use, extend, or debug the component without needing to inspect its internal implementation.
/**
* Button Component
*
* Reusable button for primary, secondary, or outlined actions across the app.
*
* @param {ButtonProps} props - The props for the button.
* @returns {JSX.Element} The rendered button element.
*
* @example
* <Button label="Save" onClick={handleSave} variant="primary" />
*/