Frontend Engineering

Designing a Complex UI with Components: My Approach to User Management

Afzal AhmedFaz Ahmed
·27 July 2026·22 min read
Component ArchitectureAngularReactBlazorState ManagementDesign SystemsAccessibilityEnterprise UI
Annotated BuildEstate Pro user management screen showing global search, advanced filters, summary cards, bulk operations, a reusable data table, row actions and pagination
Annotated BuildEstate Pro user management screen showing global search, advanced filters, summary cards, bulk operations, a reusable data table, row actions and pagination

Why This Matters

I use a realistic BuildEstate Pro user-management screen to explain how I turn a complex enterprise workflow into clear component boundaries, sensible state ownership and maintainable UI architecture.

When I design an enterprise screen, I try not to begin with the markup. I begin with the work that someone needs to complete.

The User Management concept shown above is a useful example because it contains many of the things that make a real interface difficult: navigation, two different kinds of search, advanced filters, summary metrics, selectable rows, bulk actions, pagination, export, permissions and responsive behaviour. Each feature is straightforward in isolation. The challenge is making them work together without allowing the page to become one tightly coupled component.

In this article, I want to walk you through how I approach that challenge. I will explain the questions I ask, where I draw component boundaries, how I decide who owns state and how I keep shared UI concerns separate from business behaviour.

I am using a BuildEstate Pro user-management design as the working example, but the approach is framework-neutral. I have used the same principles in Angular applications, and they transfer naturally to React and Blazor. The syntax changes; the responsibility of each part does not.

The question I keep returning to is simple:

How can I turn a visually complex screen into a set of understandable, maintainable and testable responsibilities?

Let’s work through the design together.


1. I start with the user journey, not the component tree

Before I split the UI, I want to understand what this page is for.

This screen is User Management inside an administration module. The user visiting this page is probably an admin, super admin, project administrator, or internal operations user.

Their goals are likely:

They want to find users quickly. They want to filter users by status, role, department, login date, email verified, locked status and two-factor authentication. They want to see account health: total users, active users, inactive users, locked users and new users this month. They want to select multiple users and perform bulk operations. They want to export data. They want to create a new user. They want to view, edit or open more actions for a specific user. They want to paginate large data.

That is the real page.

The screen is not “a table with filters.” The screen is an admin workflow surface.

That phrase matters.

When we design components, we design around the workflow.

The workflow is:

Admin enters User Management
  ↓
Admin searches or filters users
  ↓
System shows summary metrics and matching users
  ↓
Admin selects one or more users
  ↓
Admin performs row action or bulk action
  ↓
System updates data and feedback

This gives me a clear model for the page.

A good component architecture should make that workflow obvious.


2. I identify the major regions of the screen

Looking at the design, the page naturally divides into regions.

There is a dark left sidebar.

There is a top bar with breadcrumbs, global search, notification icon and user avatar.

There is a page header: “User Management” and subtitle.

There are top-right page actions: export and new user.

There is a left filter panel.

There are summary cards.

There is a selected-results toolbar.

There is the main users table.

There is pagination.

There are bottom feature/help cards.

There are annotation callouts explaining the design.

In component thinking, the first cut is not tiny components. The first cut is layout regions.

A possible top-level component tree might look like this:

AppShell
  ├── SidebarNavigation
  ├── TopNavigationBar
  │     ├── Breadcrumbs
  │     ├── GlobalSearch
  │     ├── NotificationButton
  │     └── UserAvatarMenu
  │
  └── MainContent
        └── UserManagementPage
              ├── PageHeader
              │     ├── PageTitle
              │     └── PageActions
              │
              ├── UserManagementLayout
              │     ├── UserFiltersPanel
              │     └── UserManagementContent
              │           ├── UserSummaryCards
              │           ├── UserSelectionToolbar
              │           ├── UsersTable
              │           └── PaginationBar
              │
              └── UserManagementFeatureHints

This is already much clearer.

Now we can ask:

Which components are application shell components? Which components are page-specific? Which components are reusable across modules? Which components are business-specific to User Management? Which components are pure presentation? Which components coordinate state?

This is where architecture begins.


3. Separate shell components from page components

The left sidebar and top navigation are not part of the User Management feature. They belong to the application shell.

The shell is the frame around the application.

In this design, the shell includes:

Sidebar module navigation. Top breadcrumb area. Global search. Notifications. User profile menu. Collapse sidebar control.

This should not be built inside UserManagementPage.

An approach I would avoid:

UserManagementPage
  ├── Sidebar
  ├── TopBar
  ├── Filters
  ├── Table

Why is this bad?

Because every page would repeat the sidebar and top bar. Later, when the sidebar changes, you update ten pages. That is not component design; that is duplication.

Better design:

AppShell
  ├── SidebarNavigation
  ├── TopNavigationBar
  └── PageOutlet
        └── UserManagementPage

In React, PageOutlet might be React Router’s . In Angular, it might be . In Blazor, it might be @Body inside MainLayout.

Same idea.

The shell owns navigation structure. The page owns page content.

My rule of thumb:

Do not make feature pages responsible for the application frame.

That one rule saves a lot of mess.


4. Page component: the orchestrator

Now we come to the main page: UserManagementPage.

This should be the orchestrator. It should not render every table cell manually. It should not know every SVG icon. It should not contain 900 lines of markup.

Its job is to coordinate the page use case.

It owns or coordinates:

Current filters. Current search text. Current sort. Current page number and page size. Selected user IDs. Data loading state. Bulk action execution. Navigation to create/edit/view pages. Refresh after changes.

A page component is like a conductor of an orchestra. It does not play every instrument. It tells sections when to come in.

A good UserManagementPage mental structure:

UserManagementPage
  - Reads route/query parameters
  - Holds page-level UI state
  - Calls data/service/query layer
  - Passes data into child components
  - Receives events from child components
  - Triggers commands/actions

In framework-neutral pseudocode:

UserManagementPage
  state:
    filters
    searchText
    sort
    pagination
    selectedUserIds

  data:
    usersResult
    userSummaryMetrics

  actions:
    applyFilters()
    resetFilters()
    selectUser()
    selectAllUsers()
    clearSelection()
    exportUsers()
    createUser()
    editUser()
    lockUser()
    bulkDeactivate()

Now the child components become easier to design.

The page does not need to know how the filter dropdown is styled. The table does not need to know how API calls work. The row action button does not need to know how the page query is built.

Each component has a job.


5. Think in three component categories

For a complex UI like this, I like to classify components into three categories.

First, layout components. These arrange the page but do not know much business logic.

Examples:

AppShell
MainContent
TwoColumnPageLayout
CardGrid
Toolbar

Second, feature components. These understand the User Management domain.

Examples:

UserFiltersPanel
UserSummaryCards
UsersTable
UserSelectionToolbar
UserRoleBadge
UserStatusBadge
UserRowActions

Third, shared UI components. These are reusable building blocks.

Examples:

Button
IconButton
SearchInput
Select
DateRangePicker
Badge
Card
Table
DropdownMenu
Pagination
Checkbox
Avatar
Tooltip

This classification is powerful.

If a component is reusable across many modules, it goes into shared UI.

If it knows about users, roles, departments or account status, it belongs to the User Management feature.

If it only arranges content, it belongs to layout.

A common mistake is mixing these categories.

For example, a shared Table component should not know what a “Super Admin” is. That belongs to UserRoleBadge or UsersTable.

Too tightly coupled:

SharedTable knows:
  - User status
  - Role colours
  - Department names
  - Lock logic

Better:

SharedDataTable knows:
  - rows
  - columns
  - sorting
  - selection
  - pagination

UserManagement defines:
  - which columns
  - how to render role badges
  - how to render status badges
  - what row actions exist

My rule of thumb:

Shared components should be generic. Feature components should understand the business.


6. The filter panel: one of the most important components

The design has a left filter panel. I treat it as a key workflow component rather than decoration because it drives the page.

Filters include:

Search. Status. Role. Department. Last login. Created date. Email verified. Account locked. Two-factor auth. More filters collapse/expand. Apply filters. Clear all. Active filter count badge.

This deserves careful design.

A weak implementation puts every filter field directly inside the page component. The page becomes huge.

A stronger design creates:

UserFiltersPanel
  ├── SearchFilter
  ├── SelectFilter
  ├── DateRangeFilter
  ├── AdvancedFiltersSection
  └── FilterActions

The panel should receive the current filter model and emit changes.

Framework-neutral contract:

UserFiltersPanel inputs:
  filters
  filterOptions
  activeFilterCount
  isAdvancedOpen

UserFiltersPanel outputs:
  filtersChanged
  applyRequested
  resetRequested
  advancedToggled

This design works in React, Angular or Blazor.

React-style naming:

filters
onFiltersChange
onApplyFilters
onResetFilters

Angular-style naming:

@Input() filters
@Output() filtersChange
@Output() applyFilters

Blazor-style naming:

[Parameter] Filters
[Parameter] EventCallback<Filters> FiltersChanged
[Parameter] EventCallback OnApplyFilters

Same concept.

Important question: should filters apply immediately or only when the user clicks “Apply Filters”?

The design has an Apply Filters button, which suggests the following interaction:

Draft filter state inside the panel. Applied filter state in the page query. Click Apply to reload data.

That matters.

If filters apply immediately on every dropdown change, the API may be called too often. If there are multiple filters, the user may want to set several first, then apply.

So we may have two filter models:

draftFilters = what the user is editing
appliedFilters = what the table is currently using

This is professional UI thinking.

Best practice:

For simple search, immediate filtering with debounce is fine. For complex advanced filters, use Apply and Reset. Show active filter count clearly. Keep filter state serialisable so it can go into query string if needed. Consider saving filter preferences for admin users.


7. Global search versus page search

The top bar contains a global search, while the filter panel contains a user search. I keep those responsibilities deliberately separate.

These are not the same thing.

Global search means:

“Search across the whole application.”

Maybe it searches users, projects, properties, documents, reports and modules.

Page search means:

“Search within users.”

This distinction should exist in components and state.

Do not reuse the same search handler accidentally.

GlobalSearch
  - belongs to AppShell
  - searches across modules
  - may navigate to results page

UserSearchFilter
  - belongs to UserFiltersPanel
  - filters the users table

My rule of thumb:

Same UI pattern does not mean same responsibility.

Both are search boxes visually, but they serve different use cases.


8. Summary cards: metrics, not decoration

The summary cards show:

Total Users. Active Users. Inactive Users. Locked Users. New This Month.

These are KPI cards.

They give the admin a quick overview.

Possible component structure:

UserSummaryCards
  ├── SummaryMetricCard
  ├── SummaryMetricCard
  ├── SummaryMetricCard
  ├── SummaryMetricCard
  └── SummaryMetricCard

SummaryMetricCard can be a shared component.

It may accept:

title
value
trendLabel
trendDirection
icon
variant

Example:

SummaryMetricCard
  title: "Active Users"
  value: 112
  trend: "87.5% of total"
  icon: user-check
  variant: success

But UserSummaryCards is feature-specific because it knows which metrics exist for users.

The page might fetch summary metrics separately from the table data.

Why separately?

Because summary metrics may represent all users, not just current page. They may also have different caching requirements.

For example:

GET /api/admin/users/summary
GET /api/admin/users?status=active&page=1&pageSize=10

Or they may be returned together if the backend is designed that way.

The component contract I would use:

Summary cards should not calculate totals from the current visible page unless that is explicitly intended. If the table shows 10 out of 128 users, calculating “active users” from only those 10 would be misleading.

My rule of thumb:

Know the data scope of every number on the screen.

Is “Total Users 128” all users? Filtered users? Current page users? Current tenant users? Current department users?

A good component name or API contract should make that clear.


9. The selected-results toolbar

The selection toolbar shows:

“10 results selected” “Select all 128” “Clear selection” “Bulk Actions”

This is a complex interaction hiding in a small strip.

Selection has important edge cases.

Does selecting all mean all rows on the current page? Or all 128 matching the current filter? If the user changes filters, should selection clear? If the user moves to another page, does selection remain? Can locked users be selected? Can the current logged-in admin select themselves for deactivation? Do permissions affect available bulk actions?

This is why component design must include behaviour, not just markup.

Possible component:

UserSelectionToolbar
  inputs:
    selectedCount
    totalMatchingCount
    isAllMatchingSelected
    availableBulkActions

  outputs:
    selectAllMatching
    clearSelection
    bulkActionRequested

The table should not own global selection logic alone. The page should likely own selectedUserIds, because the toolbar and table both need it.

The table displays checkboxes. The toolbar displays selection summary. Bulk actions use selected IDs.

So selection state belongs to the nearest common owner: UserManagementPage.

Best practice:

Keep selected IDs as a set, not just an array, for efficient lookup. Clear selection when filters change unless product behaviour says otherwise. Make “select all matching results” explicit because it may affect users not visible on current page. Confirm destructive bulk actions. Respect permissions. Handle partial failures from bulk operations.

Example: bulk deactivate 100 users, 95 succeed, 5 fail because they are already locked or protected. The UI should handle that.


10. Users table: the heart of the screen

The users table is the main work surface.

Columns include:

Checkbox. Name with avatar initials. Email. Roles. Department. Status. Last Login. Created On. Actions.

This table is not a simple HTML table. It is a data grid pattern.

Responsibilities:

Display data. Support row selection. Support sorting. Show role badges. Show status badges. Show row actions. Handle empty state. Possibly support column customisation. Possibly support responsive layout.

The temptation is to build one giant UsersTable with everything inside.

Instead, think composition:

UsersTable
  ├── DataTable
  │     ├── TableHeader
  │     ├── TableBody
  │     │     └── UserTableRow
  │     │           ├── UserIdentityCell
  │     │           ├── UserRoleBadges
  │     │           ├── UserStatusBadge
  │     │           └── UserRowActions
  │     └── TableEmptyState

Now each part has a job.

UserIdentityCell handles avatar initials and display name. UserRoleBadges renders role chips. UserStatusBadge renders active/inactive/locked. UserRowActions renders view/edit/more menu. DataTable handles generic table structure.

A reusable DataTable can be used for users, projects, documents, roles, audit logs and reports.

But the cell renderers are feature-specific.

My rule of thumb:

The table framework should be reusable; the cells should express the business.


11. Sorting: column headers are behaviour

Your table has sortable columns. The visual arrows are not decoration.

Sorting affects query state.

Sort model:

sortColumn: "name" | "email" | "lastLogin" | "createdOn"
sortDirection: "asc" | "desc"

Where should sort state live?

Usually the page owns it, because sorting reloads or reshapes the data.

The table receives:

sort
onSortChange

When the user clicks a column header, the table emits sort change. The page updates query state. Data reloads.

For small data, sorting can be client-side. For real admin user management, sorting should usually be server-side because there may be hundreds or thousands of users.

Best practice:

Use server-side sorting for large datasets. Make sort state visible in the URL if the page should be shareable. Do not sort formatted strings if the backend has real date/numeric values. Make sure backend sort fields are whitelisted to avoid unsafe dynamic SQL.

This is where full-stack thinking matters. A UI sort arrow can become a SQL performance problem if designed badly.


12. Pagination: more than page numbers

The bottom pagination shows page numbers and page size.

Pagination state includes:

pageNumber
pageSize
totalCount
totalPages

A good pagination component should be generic.

PaginationBar
  inputs:
    pageNumber
    pageSize
    totalCount
    pageSizeOptions

  outputs:
    pageChanged
    pageSizeChanged

The page owns the pagination state. The table does not fetch data by itself.

When filters change, reset page number to 1.

Why?

If the user is on page 13 and applies a restrictive filter, there may not be page 13 anymore.

Best practice:

Reset page to 1 when filters/search change. Keep page size user preference if useful. Show “Showing 1 to 10 of 128 users.” Disable previous/next appropriately. Use server-side pagination for serious data. Avoid loading all users into the frontend and paginating in memory.

My rule of thumb:

Pagination is a backend contract, not only a frontend widget.


13. Row actions: small UI, serious permission logic

Each user row has actions: view, edit, more.

This looks simple but requires thought.

Who can view? Who can edit? Can an admin edit a super admin? Can users edit themselves? Can locked users be unlocked? Can inactive users be reactivated? Should delete exist or should it be deactivate? Should actions be hidden or disabled when not allowed?

Possible component:

UserRowActions
  inputs:
    user
    permissions

  outputs:
    viewRequested
    editRequested
    deactivateRequested
    resetPasswordRequested
    unlockRequested

Do not put permission checks only in the UI.

Frontend permission checks improve experience. Backend authorization protects the system.

The row actions should render based on permissions, but the API must still enforce every action.

Best practice:

Make destructive actions require confirmation. Use clear action labels. Do not hide all actions silently if user lacks permission; sometimes disabled with explanation is better. Log administrative actions. Protect special users such as the current user or root admin.

This is corporate admin software, not a toy screen.


14. Badges, chips and avatars: small reusable design components

The screen uses many small visual elements:

Role badges: Super Admin, Admin, Project Manager. Status badges: Active. Avatar initials: AA, LA, EC. Filter count badge. Notification badge.

These should become reusable components.

Badge
StatusBadge
RoleBadge
AvatarInitials
CountBadge

But again, separate generic from business-specific.

Generic:

Badge
  label
  variant
  size

Feature-specific:

UserRoleBadge
  role
  maps role to label/variant

If every page hardcodes colours and labels, the design becomes inconsistent.

Best practice:

Centralise role/status visual mapping. Use accessible colour contrast. Do not communicate status only by colour. Keep labels readable. Use consistent sizing and spacing.

For example, “Active” should not only be green; it should also say “Active”.

That helps accessibility and clarity.


15. State model for the whole page

Now let’s model the state.

A serious page like this has several state categories.

Server state:

users
totalCount
summaryMetrics
roleOptions
departmentOptions

Query/UI state:

searchText
filters
sort
pageNumber
pageSize

Selection state:

selectedUserIds
isAllMatchingSelected

Interaction state:

isAdvancedFiltersOpen
isBulkActionMenuOpen
activeRowMenuUserId
isExporting
isCreatingUser

Permission state:

canCreateUser
canExportUsers
canEditUsers
canBulkUpdateUsers

Error/loading state:

isLoadingUsers
usersError
isLoadingSummary
summaryError
bulkActionError

If you do not name state clearly, the component becomes messy.

The way I model this state:

Every piece of state should have an owner and a reason to exist.

Do not store derived state unnecessarily.

For example:

activeFilterCount

can probably be calculated from filters.

selectedCount

can be calculated from selectedUserIds.size.

But loading state from API is real state.

The page should not become a dumping ground, but it is normal for it to coordinate state. To avoid huge pages, move behaviour into hooks/services/state classes depending on framework.

React:

useUserManagementPage()
useUsersQuery()
useUserSelection()
useUserFilters()

Angular:

UserManagementFacade
UserManagementStore
UserService
FilterFormGroup

Blazor:

UserManagementPage.razor
UserManagementStateService
UserApiClient
Reusable components with EventCallback

Different tools, same underlying responsibilities.


16. Data flow: how the screen comes alive

A professional screen has a predictable data flow.

Initial load:

UserManagementPage mounts/initialises
  ↓
Load filter options
  ↓
Load summary metrics
  ↓
Load users page 1
  ↓
Render loading states
  ↓
Render data

Filter apply:

User edits draft filters
  ↓
Clicks Apply Filters
  ↓
Page copies draft filters to applied filters
  ↓
Page resets pageNumber to 1
  ↓
Users query reloads
  ↓
Table updates
  ↓
Selection clears

Sort:

User clicks Created On
  ↓
Table emits sortChanged
  ↓
Page updates sort state
  ↓
Users query reloads

Bulk action:

User selects rows
  ↓
Selection toolbar appears
  ↓
User chooses Bulk Action
  ↓
Confirm modal opens
  ↓
API command executes
  ↓
Success/error feedback shown
  ↓
Users and summary reload
  ↓
Selection clears

This is how you think before coding.

If you cannot explain the data flow, the code will become accidental.


17. Component communication rules

Across React, Angular and Blazor, communication should remain clean.

Parent to child:

Data goes down.

Child to parent:

Events go up.

Shared service/store:

Used when distant components need shared state or coordination.

Avoid random cross-component mutation.

The coupling I try to avoid:

“Filter component directly tells table to reload.”

Better:

“Filter component emits filter changes to page. Page updates query state. Table receives new data.”

Why?

Because the page is the use-case coordinator. The filter panel should not know the table exists. The table should not know the filter panel exists. Both are children of the page.

This keeps components reusable and easier to test.

My rule of thumb:

Sibling components should not secretly control each other. Coordinate through their parent or a deliberate state service.


18. Responsiveness and layout thinking

This screen is wide. It looks like a desktop admin interface.

But what happens on smaller screens?

The sidebar may collapse. The filter panel may become a drawer. The table may become horizontally scrollable or turn into cards. Summary cards may wrap. Action buttons may collapse into a menu. Columns may hide or become configurable.

A component design should support this.

For example:

UserManagementLayout
  desktop:
    filters left, content right

  tablet:
    filters collapsible, content full width

  mobile:
    filters drawer, table cards or scroll

Do not hardcode layout assumptions into every component.

Make layout components responsible for layout.

Best practice:

Separate layout from business rendering. Use responsive grid/flex rules consistently. Avoid fixed widths everywhere. Decide which table columns are essential on smaller screens. Keep actions accessible.

Enterprise apps often start desktop-first, but users still resize windows, use tablets, or work on laptops with limited space.


19. Accessibility and keyboard behaviour

A serious admin screen must be usable beyond mouse clicks.

Questions:

Can the sidebar be navigated by keyboard? Do dropdowns have proper keyboard handling? Are filters labelled? Are table headers announced properly? Are sortable columns accessible? Do checkboxes have labels? Can row actions be opened with keyboard? Are status colours accompanied by text? Do modals trap focus correctly?

A reusable component library helps here.

If every team builds its own dropdown, modal and table from scratch, accessibility becomes inconsistent.

Best practice:

Use semantic HTML where possible. Use real buttons for actions. Use labels for inputs. Use aria-sort for sortable table headers. Use role="alert" for important validation messages. Do not rely only on colour. Test keyboard navigation.

I do not treat accessibility as decoration. It is part of the quality of the feature.


20. Performance thinking

A user management page can become slow if badly designed.

Possible performance risks:

Loading all 128 or 10,000 users at once. Filtering in the frontend instead of backend. Rendering too many rows. Re-rendering the whole table on every keystroke. Fetching summary and users repeatedly. Large role/permission objects in every row. Unstable callbacks causing unnecessary rendering. Opening menus causing full page re-render.

Solutions:

Server-side pagination. Server-side filtering and sorting. Debounced search. Memoised column definitions if needed. Virtualised table for very large row counts. Separate summary query from table query. Cache filter options. Do not over-fetch columns. Use DTOs shaped for this screen.

But do not optimise blindly.

The first performance design decision is the API contract.

For this screen, a good API might be:

GET /api/admin/users
  query:
    search
    status
    role
    department
    lastLogin
    createdFrom
    createdTo
    emailVerified
    locked
    twoFactorEnabled
    sortBy
    sortDirection
    pageNumber
    pageSize

  returns:
    items[]
    totalCount
    pageNumber
    pageSize

This keeps the frontend lean.

My rule of thumb:

The fastest table is the one that never receives unnecessary data.


21. Testing strategy

This screen needs multiple levels of testing.

Component tests:

Filter panel emits correct filter model. Summary card renders correct values. Status badge maps statuses correctly. Pagination emits page changes. Users table renders rows and selected states.

Integration tests:

Applying filters reloads users. Selecting rows shows toolbar. Bulk action triggers confirmation. Export button calls export flow. Sort header changes sort state.

End-to-end tests:

Admin can search users. Admin can create a new user. Admin can select users and apply bulk action. Admin without permission cannot see restricted actions.

Accessibility tests:

Inputs have labels. Buttons are reachable. Table headers are correct. Menus and modals behave properly.

A screen like this is too important to rely only on manual clicking.


22. How to approach building this page step by step

This is how I would mentor a developer to build it.

Step one: build the shell separately.

Do not start with the users table. First create the app frame: sidebar, top bar, content outlet.

Step two: build the static page skeleton.

Page header, actions, filter panel placeholder, summary cards placeholder, table placeholder.

Step three: define the data contracts.

User list item DTO. Filter model. Summary model. Pagination model. Sort model. Bulk action model.

Step four: build presentational components with mock data.

User card, badges, table row, summary card, filters.

Step five: wire page state.

Filters, sort, pagination, selected users.

Step six: connect API.

Load users, load summary, load filter options.

Step seven: handle all UI states.

Loading, error, empty, success.

Step eight: implement actions.

New user, export, row view/edit, bulk actions.

Step nine: add permissions.

Hide/disable actions based on permission model.

Step ten: polish responsiveness, accessibility and tests.

This order prevents chaos.

An implementation order I would avoid:

Start coding the table with live API, filters, bulk actions and styling all at once.

That creates confusion.

My delivery principle:

Build the screen from stable structure to dynamic behaviour.


23. Production mentoring case: administer users without losing control

The original decomposition gives every visible feature a home. Now I want to take you through the less visible decisions that make the page dependable.

Assume BuildEstate Pro has many organisations. An organisation administrator can search users in their organisation, invite a user, change approved roles, lock an account and export a permitted subset. A platform support operator has broader but audited capabilities. The system contains personal data, and several administrators can work at once.

Junior: We already have a table, filters and action buttons. What makes the production version harder?
>
Senior: Time and authority. Responses arrive out of order, records change elsewhere, permissions differ by resource, actions may affect thousands of users, and the UI must never imply an outcome the server did not confirm.
Write a capability matrix before component APIs:
CapabilityOrganisation adminSupport operatorServer enforcement
View user summaryOwn organisationAssigned organisationsTenant-scoped query
Invite userIf seat/policy permitsWith explicit support permissionCommand policy and domain rules
Change roleApproved roles onlyApproved support rolesResource authorisation
Lock accountNot self; within organisationAudited support pathCommand invariant and policy
ExportRestricted columns/sizeCase-linked accessExport policy and job
The table is not the policy implementation. It tells designers which affordances exist and backend developers which rules must be authoritative.

Define a page contract

The page reads a purpose-built model rather than domain entities:

export interface UserListItem {
  id: string;
  displayName: string;
  email: string;
  status: 'active' | 'invited' | 'locked' | 'disabled';
  roleNames: readonly string[];
  departmentName: string | null;
  lastSignedInAt: string | null;
  version: string;
  permittedActions: readonly UserAction[];
}

export type UserAction =
  | 'view'
  | 'edit-profile'
  | 'change-roles'
  | 'lock'
  | 'unlock'
  | 'disable';

version supports optimistic concurrency. permittedActions gives the UI resource-aware presentation without teaching it every policy. The server still re-authorises each command because permissions can change after the query.

Use strings for dates on the wire with a documented instant/offset format, then parse deliberately. Do not send a JavaScript Date through JSON and assume timezone meaning survives.

The query contract is similarly explicit:

export interface UserQuery {
  search: string;
  statuses: readonly UserStatus[];
  roleIds: readonly string[];
  departmentIds: readonly string[];
  emailVerified: boolean | null;
  twoFactorEnabled: boolean | null;
  locked: boolean | null;
  lastSignedInFrom: string | null;
  lastSignedInToExclusive: string | null;
  sort: UserSort;
  page: number;
  pageSize: 25 | 50 | 100;
}

export interface PagedUsers {
  items: readonly UserListItem[];
  page: number;
  pageSize: number;
  totalItems: number;
  queryFingerprint: string;
}

Avoid a dictionary of arbitrary filters if the server supports a known set. A typed contract makes URL parsing, validation, analytics and compatibility review possible.

24. Separate draft filters from applied query state

A complex filter panel often needs two states:

  • draft filters are what the user is currently editing;
  • applied query is what produced the visible table.
Without this distinction, every checkbox may fire a request and the summary, URL and table can disagree during editing.
interface UserManagementState {
  draftFilters: UserFilters;
  query: UserQuery;
  result: PagedUsers | null;
  selection: SelectionState;
  load: LoadState;
}

type LoadState =
  | { kind: 'idle' }
  | { kind: 'loading'; requestId: number }
  | { kind: 'loaded'; requestId: number }
  | { kind: 'failed'; requestId: number; message: string };

Pressing Apply copies validated draft filters into the query, resets page to one, writes the URL and loads. Pressing Clear restores known defaults rather than mutating fields piecemeal. The filter badge count derives from applied filters, not raw object keys.

Junior: Why not bind the form directly to query parameters?
>
Senior: That can work for simple instant filters. For a large panel with Apply/Cancel, draft state prevents half-edited criteria from changing the result and gives one predictable history entry.
If product wants live filtering, debounce only text-like inputs and apply categorical changes immediately according to a documented rule. “Debounce everything” can make checkboxes feel unresponsive.

25. Make the URL a durable navigation contract

Search, filters, sort and page should survive refresh and support a shareable link when permissions allow. Encode stable business values, not component internals:

/admin/users?q=ana&status=active,invited&role=site-manager&sort=-lastSignIn&page=2

Parsing is validation. Unknown status values, negative pages and unsupported sort columns should fall back safely or produce a controlled error. Do not construct SQL sort expressions from the raw parameter.

Use a canonical encoder so equivalent state produces one URL. Remove defaults to keep it readable. Decide history behaviour:

  • typing debounced search usually replaces the current entry;
  • pressing Apply creates a meaningful new entry;
  • pagination commonly creates an entry so Back returns to the previous page;
  • opening/closing a transient menu should not touch history.
On initial load, URL state is authoritative. Avoid loading defaults first and URL state second, which causes duplicate requests and visual jumps.

Preserve focus and announce navigation effects. Browser Back must restore filter controls and table state, not only the address bar.

26. Prevent stale-response races

An administrator types “ann,” then quickly changes it to “anna.” The first request is slower and arrives last. If the component assigns every response, old data replaces the correct result.

Use cancellation and identity checking:

let requestSequence = 0;
let activeController: AbortController | null = null;

async function loadUsers(query: UserQuery): Promise<void> {
  activeController?.abort();
  const controller = new AbortController();
  activeController = controller;
  const requestId = ++requestSequence;

  dispatch({ type: 'loadStarted', requestId });

  try {
    const result = await api.getUsers(query, controller.signal);

    if (requestId === requestSequence) {
      dispatch({ type: 'loadSucceeded', requestId, query, result });
    }
  } catch (error) {
    if (controller.signal.aborted) return;
    if (requestId === requestSequence) {
      dispatch({ type: 'loadFailed', requestId, error: toUiError(error) });
    }
  }
}

Aborting saves work when the browser/client/server honours it. Checking requestId protects the UI even if cancellation arrives too late. In RxJS, an outer stream with switchMap can express the same latest-query semantics. In Blazor, cancel a token source and compare captured request identity.

Keep the current table visible during a background refresh when that reduces disorientation, but mark it as updating and disable actions that would use stale versions. For the first load, use a meaningful skeleton or status. Treat initial loading, refresh, empty result and error as distinct states.

27. Selection needs a mathematical model

Selection becomes difficult when filtering and pagination exist. Define its scope before coding.

For ordinary page selection:

interface ExplicitSelection {
  mode: 'explicit';
  selectedIds: ReadonlySet<string>;
}

“Select all matching 48,321 users” cannot place every ID in browser memory. Use a query-based model:

type SelectionState =
  | { mode: 'explicit'; selectedIds: ReadonlySet<string> }
  | {
      mode: 'all-matching';
      queryFingerprint: string;
      excludedIds: ReadonlySet<string>;
      matchingCount: number;
    };

The UI first selects the visible page, then offers “Select all 48,321 matching users.” If filters change, invalidate the all-matching selection because its fingerprint no longer describes the current result.

Always show scope in the toolbar: “25 selected on this page” or “All 48,321 matching users selected; 2 excluded.” Ambiguity before a destructive action is a design defect.

Do not trust the count or IDs from the browser. The command service resolves the authorised current target set, applies limits and handles records that changed between selection and execution.

Junior: Should selected users remain selected when I change page?
>
Senior: That is a product choice. Whichever rule you choose, make it visible and testable. Hidden cross-page selection is dangerous.

28. Design safe bulk operations

Bulk lock, role change or disable can affect many people and may take longer than one request. Model it as a job when scale or auditability requires it.

interface BulkUserCommand {
  requestId: string;
  target:
    | { kind: 'ids'; userIds: readonly string[] }
    | { kind: 'query'; queryFingerprint: string; exclusions: readonly string[] };
  action: 'lock' | 'disable' | 'assign-role';
  roleId?: string;
  reason: string;
}

The server should not accept only a fingerprint it cannot resolve. Store or sign a server-owned selection snapshot, or send a validated query contract that the server resolves under current permissions. Set expiry so an old selection cannot be replayed after membership changes indefinitely.

The confirmation dialog should state action, scope, irreversible consequences and expected duration. Require a reason where audit policy needs it. Do not use colour alone to signal danger.

Return an operation resource:

{
  "operationId": "op_9d33",
  "status": "queued",
  "targetCount": 48321,
  "statusUrl": "/api/user-operations/op_9d33"
}

The page can show progress and allow navigation away. The job records succeeded, skipped and failed targets with bounded reason categories. Partial success is not a generic failure toast.

Commands need idempotency. A lost response followed by retry must not create two jobs or apply the action twice. Use a request ID and request fingerprint. Authorise at execution time as well as submission, especially for delayed jobs.

Some actions should exclude the current user, last organisation owner or protected service accounts. Those are backend invariants. The UI can explain them before submission and render per-row permitted actions, but the job must re-evaluate.

29. Optimistic updates need a rollback contract

Optimistic UI is suitable when success is likely, reversal is clear and a wrong temporary display is low risk. Toggling a harmless preference qualifies. Disabling a user account in an audited system usually deserves confirmed server state.

For a row edit, send its expected version:

PATCH /api/users/4f9...
If-Match: "v17"
Content-Type: application/json

{ "departmentId": "d2" }

On 412 Precondition Failed or the chosen conflict response, keep the admin's intended change, fetch current data and explain what changed. Do not silently overwrite or replace the form with server values.

If you optimistically update presentation, retain the prior value and operation identity. A later failure should roll back only if a newer edit has not superseded it. This is another stale-result problem.

After a successful row command, update that row from the authoritative response or invalidate/refetch the relevant query. Recomputing summary metrics locally can be wrong if filters or server rules are complex. Decide whether to refresh summaries separately and mark their freshness.

30. Permissions are data and policy, not scattered conditions

A component full of conditions such as currentUser.isAdmin && user.id !== currentUser.id duplicates policy imperfectly.

Use a feature-level permission model for coarse capabilities and resource-level actions from the query:

interface UserManagementCapabilities {
  canInvite: boolean;
  canExport: boolean;
  canSelectAllMatching: boolean;
  maximumSynchronousExportRows: number;
  assignableRoles: readonly RoleOption[];
}

Pass a focused capability to components rather than the entire authentication object. A row action menu renders only permittedActions. This improves consistency and testability.

Still enforce every rule server-side. Client-side state is observable and modifiable. A malicious caller can invoke a hidden endpoint, submit another organisation ID or reuse a stale action.

Distinguish unavailable reasons. If a user cannot lock their own account, a disabled menu item with explanation may teach the constraint. If revealing the existence of an action is sensitive, omit it. Accessibility requires disabled controls to have understandable context; a disabled native button does not receive all interaction, so place explanation appropriately.

Permissions can change while the page is open. Treat 403 as a valid domain of failure: update capabilities, remove stale actions and explain that access changed. Do not turn it into “Unexpected error.”

31. Search has semantics, privacy and cost

Define which fields are searchable and how matching behaves. Email exact/prefix search differs from fuzzy display-name search. Case folding and accent rules depend on locale/database collation. Do not promise a “global search” that sometimes means this page's users.

Debounce text input, require a minimum length for expensive fuzzy search and cancel superseded requests. The server must apply tenant/permission filters before returning matches and should resist enumeration.

Search terms may contain personal data. Avoid sending raw terms to analytics or logging them by default. If product analytics needs usage, record bounded facts such as result-count band and latency rather than the query text.

Highlighting matches can help, but never inject a search term as raw HTML. Render text nodes or use safe segmented spans. Screen readers should receive the unbroken meaningful name/email.

For large datasets, design database indexes and full-text search from measured query patterns. A leading wildcard over millions of rows may be expensive. The UI cannot fix an unbounded backend search with a spinner.

32. Table semantics and responsive alternatives

Use an HTML table when data is genuinely tabular. Give headers proper scope and sortable headers buttons with visible names. Convey sort direction through aria-sort on the active column header, not only an arrow icon.

Selection checkboxes need labels such as “Select Ana Khan,” while the header checkbox communicates whether none, some or all visible rows are selected. The indeterminate visual state should have an accessible state/description.

Row action menus must be keyboard operable, manage focus, close predictably and return focus to their trigger. Avoid making the whole row clickable while also containing buttons and links; overlapping interactions confuse keyboard and assistive-technology users.

On narrow screens, horizontal scrolling may preserve relational comparison better than converting everything into cards. If cards are chosen, repeat field labels and retain action/selection semantics. Do not hide security-relevant status or required actions merely to fit.

Sticky headers and columns can help large tables, but test zoom, high contrast and focus visibility. Layering (z-index) errors can cover menus or focus rings. Test with real content lengths and translated labels.

Junior: Should we build an ARIA grid for spreadsheet keyboard behaviour?
>
Senior: Only if users need true grid interaction and we can implement/test the complete pattern. A native table with ordinary controls is simpler and more robust for most admin lists.

33. Accessible loading, feedback and focus

Loading is not a blank page. Keep the page title and controls available where appropriate. Mark the result region busy and provide a polite status such as “Loading users.” Do not announce every keystroke/request in a live region.

When Apply completes, announce “42 users found” without automatically moving focus away from the filter button. When a validation error prevents apply, focus the summary or first invalid field according to the form pattern.

After a destructive action:

  • show a durable confirmation containing the affected scope;
  • move focus only when the current focused element disappeared;
  • if a row is removed, choose the next sensible row/action or table heading;
  • make undo available only when the backend action is genuinely reversible.
Toast notifications alone are easily missed and often disappear too quickly. Important partial bulk results belong in a persistent operation panel or result page.

Respect reduced motion and avoid skeleton animation that distracts. Ensure colour contrast and do not encode Active/Locked only through green/red. Use text and icon meaning.

34. Component boundaries through three frameworks

The architecture transfers, but each framework offers different state tools.

Angular

The route/page component can derive query state from Router parameters, use a typed reactive form for drafts and use RxJS switchMap for latest-query loading. Presentational components use inputs and outputs. A feature store or signals can help when state is shared across sibling regions, but a global store is unnecessary for one page.

readonly result$ = this.query$.pipe(
  distinctUntilChanged(equalUserQuery),
  switchMap(query => this.api.getUsers(query).pipe(
    map(result => ({ kind: 'loaded' as const, result })),
    startWith({ kind: 'loading' as const }),
    catchError(error => of({ kind: 'failed' as const, error }))
  ))
);

React

Use route/search parameters as navigation state, local form state for drafts and a reducer when transitions become coupled. A server-state library can manage cache, cancellation and invalidation, but it does not decide selection semantics or permissions.

const query = useMemo(
  () => parseUserQuery(searchParams),
  [searchParams]
);

const users = useQuery({
  queryKey: ['users', query],
  queryFn: ({ signal }) => api.getUsers(query, signal),
  placeholderData: keepPreviousData
});

Library APIs evolve; use the version installed by the project. The architectural point is a stable query key and cancellable, latest-relevant server state.

Blazor

The page binds URL parameters, owns query/selection state and passes focused models and callbacks to children. Cancel prior load tokens and protect against stale completion. Interactive Server circuits require special care with long-lived state and scoped services; WebAssembly requires server APIs for protected data.

Across all three, do not put business authorisation in the component and do not let a generic table own user-management commands.

35. Avoid the universal data-grid trap

Teams often try to encode every feature in one reusable grid:

columns + renderers + filters + row actions + bulk actions + permissions
+ export + URL state + responsive cards + every callback

The result is a configuration language more difficult than ordinary components. Changes require understanding generic internals, and business behaviour hides in opaque callbacks.

Reuse stable mechanics: table primitives, sort header, pagination, selection checkbox, menu and status badge. Compose them inside a UsersTable that speaks user-management language. It can render UserListItem, emit UserActionRequested, and explain protected accounts.

A generic grid is justified when many screens share genuinely identical interaction contracts and a team owns it as a product with accessibility, performance and documentation commitments. Count real repeated use cases before building it.

Duplication in two early screens is often cheaper than the wrong abstraction. Extract after variation becomes visible.

36. Performance budgets and measurement

Define page-level measures:

  • time until structure and title are visible;
  • time until first meaningful rows;
  • filter-to-result latency;
  • interaction responsiveness for selection/menu;
  • JavaScript/bundle cost for the feature;
  • API payload and database work;
  • memory with maximum supported page and selection.
Pagination is the primary scale boundary. Returning 100 rows with narrow fields is usually preferable to shipping every user and hiding rows with CSS.

Virtualisation helps render very large visible collections but complicates focus, variable heights, screen-reader context and “select all.” It does not reduce API/database cost unless paired with incremental loading. Use it after measuring a rendering bottleneck.

Memoisation can reduce repeated calculation or child rendering, but it adds dependency/equality complexity. First keep props stable, derive state in one place and avoid rebuilding the component tree unnecessarily. In Angular, appropriate change detection and stable tracking keys matter; in React, stable keys and server-state caching matter; in Blazor, stable @key and restrained rendering matter.

Do not use array index as a row identity when sorting/pagination changes. Use the user ID. Identity mistakes can attach selection, local edit state or focus to the wrong person.

Measure with production-shaped names, roles and datasets, on representative devices and networks. A dev machine rendering ten users proves very little.

37. Export is a backend workflow

Client-side CSV export of the current page can be useful when labelled “Export this page.” It is not an export of all matching users.

For a large authorised export, submit a server job containing a validated query and requested columns. Re-authorise fields and scope. Generate in a controlled worker, store encrypted output with short expiry and return a one-time or protected download reference.

Prevent spreadsheet formula injection: values beginning with characters interpreted as formulas by spreadsheet tools need an approved escaping strategy. Correct CSV quoting alone does not address formula execution. Test commas, quotes, line breaks, Unicode and large values.

Log who requested the export, purpose/reason where policy requires, query scope, columns, count and download. Do not log the exported records. Rate-limit and cap exports to protect data and capacity.

If the job completes partially, do not offer a file that looks complete without warning. Prefer fail-and-retry or publish a manifest with explicit rejected records according to the product's needs.

The UI should show queued, running, completed, expired and failed states. A user can leave and return through an Exports area. This is better than holding a browser request for several minutes.

38. Testing pyramid for the whole screen

Pure state tests

Test query parsing/encoding, reducer transitions, selection algebra, filter count and bulk target construction without rendering a framework.

Examples:

  • applying filters resets page to one;
  • a response with an old request ID is ignored;
  • changing query clears all-matching selection;
  • excluding a row from all-matching changes the visible count;
  • URL encode/decode round-trips canonical state;
  • unsupported sort input falls back safely.

Component tests

Test FilterPanel labels, validation and Apply/Cancel; UsersTable semantics, sorting and menus; SelectionToolbar scope text and confirmation; error/empty/loading regions; focus after row removal.

Avoid snapshots containing the entire page. Assert accessible role/name and meaningful behaviour.

API/contract tests

Prove filters and sort mapping, tenant scoping, resource actions, concurrency, idempotency and export permission. The server must reject forbidden user IDs even if the UI never shows them.

Browser journeys

Use a small set:

  1. Open a deep link with filters and operate Back/Forward.
  2. Race two searches and verify the newest result remains.
  3. Select across pages, change filter and observe explicit selection reset.
  4. Attempt a stale role change and recover from conflict.
  5. Submit a large bulk job and revisit its status.
  6. Complete the primary journey with keyboard and zoom.
Automated accessibility scanning complements manual keyboard and screen-reader-informed review.

Visual regression

Capture representative states at supported widths: long names, many roles, empty, error, loading, partial selection, open menu, high zoom and translated text. Do not use a screenshot as the only functional assertion.

39. Diagnose four realistic UI incidents

Incident one: filters show “Anna,” table shows “Ann”

The older request arrived last. Confirm network timing and state transitions. Add abort plus request identity or latest-stream switching. Test with controllable delayed promises.

Incident two: bulk disable affects unexpected users

The UI retained cross-page selection after filters changed. Audit selection state, query fingerprint and confirmation text. On the server, inspect how targets were resolved and permissions re-evaluated. Fix both UI scope communication and backend target contract.

Incident three: support user sees another tenant after switching

A cache key omitted tenant scope, or a long-lived service retained the previous tenant. Treat as a security incident. Stop exposure, trace requests/cache entries, invalidate data and add tenant to every authoritative query/cache key. Client state clearing is not sufficient.

Incident four: table becomes unusable for keyboard users

A custom grid added div-based cells and clickable rows; focus disappears under virtualisation. Restore native semantics where possible, establish roving/grid behaviour only if truly required, keep focused items mounted or move focus intentionally, and test the supported pattern with assistive technology.

Incident reviews should update component contracts, automated tests, design-system guidance and runbooks—not only the line that failed.

40. A reducer makes transitions reviewable

When page state has related transitions, a reducer can make rules explicit:

type Action =
  | { type: 'draftChanged'; draft: UserFilters }
  | { type: 'filtersApplied'; query: UserQuery }
  | { type: 'loadStarted'; requestId: number }
  | { type: 'loadSucceeded'; requestId: number; result: PagedUsers }
  | { type: 'loadFailed'; requestId: number; message: string }
  | { type: 'rowSelectionToggled'; userId: string }
  | { type: 'allMatchingSelected'; fingerprint: string; count: number }
  | { type: 'selectionCleared' };

The reducer should be pure. Network calls, URL writes and announcements happen in effects around it. This makes transitions deterministic across frameworks.

Do not store derived values independently unless necessary. selectedCount, “has active filters” and “can bulk lock” can derive from selection, query and capabilities. Duplicate state drifts.

Likewise, avoid storing the same query in form, component fields, global store, URL and caching library without one authority and explicit synchronisation. More state libraries do not solve unclear ownership.

Junior: When should state become global?
>
Senior: When its lifetime and consumers are genuinely application-wide. User-list selection usually belongs to this route. Authentication identity, global notification infrastructure and shell preferences may belong higher.

41. Delivery sequence for a real team

Slice one: read-only deep link

Implement route/query parsing, page frame, server-scoped user query, loading/error/empty states and table semantics. Add trace correlation and an API integration test. Deploy behind a permission/feature flag.

Slice two: filtering and navigation

Add draft/applied filters, canonical URL, sorting and pagination. Test races, Back/Forward and reset rules. Review query plans with representative data.

Slice three: one row command

Implement one protected action end-to-end with version, resource authorisation, idempotency, audit and conflict UI. Do not add every menu item before this contract is proven.

Slice four: selection and bulk operation

Begin with visible-page explicit selection. Add all-matching only after its server snapshot/fingerprint and confirmation semantics pass review. Operate it as a durable job.

Slice five: export and polish

Add governed export, responsive behaviour, keyboard review, performance budgets, telemetry, support runbook and progressive rollout.

At each slice, demonstrate failure as well as success. A feature is not done because the happy-path screenshot matches the design.

42. Localisation, content and time are component inputs

Enterprise interfaces often fail localisation because components were sized around English labels and dates were formatted inside data services.

Treat display text as content supplied through a localisation system. Buttons need enough room for expansion; table headers may wrap; translated role names can be much longer; right-to-left layout changes icon placement and directional assumptions.

Do not use a translated label as a programmatic key:

type UserSortField =
  | 'displayName'
  | 'email'
  | 'status'
  | 'lastSignedInAt';

interface SortOption {
  field: UserSortField;
  labelKey: string;
}

The stable field travels through URL/API allow-lists; the label renders for the current locale. Likewise, status is a stable code mapped to localised text, not a localised string stored in the database.

Names do not universally split into first and last. Use the domain's structured name fields where required, but render an approved display name rather than inventing initials from English assumptions. Avatar alternatives should identify the user meaningfully; decorative initials can be hidden from assistive technology when adjacent text already names them.

Dates and relative time

The API should send an unambiguous instant. The UI formats it for the user's locale/timezone:

const formatter = new Intl.DateTimeFormat(locale, {
  dateStyle: 'medium',
  timeStyle: 'short',
  timeZone: userTimeZone
});

const label = formatter.format(new Date(user.lastSignedInAt));

If lastSignedInAt is null, render “Never signed in,” not an invalid date. A relative label such as “3 hours ago” should have an exact timestamp available, for example in nearby text or an accessible description. Refresh relative labels at a sensible interval; do not rerender every second.

Date-range filters need explicit semantics. A user selecting 30 July expects a local business date, while the server query needs start/end instants. Resolve the timezone deliberately and use an exclusive upper boundary. Daylight-saving days are not always 24 hours.

Truncation and overflow

Ellipsis can hide the part of an email or role that distinguishes users. Provide access to the full value without relying on pointer hover alone. Prefer responsive column priority, wrapping or detail view. Test long unbroken text and maliciously large content; CSS overflow and server length validation both matter.

Junior: Can localisation wait until the screen is finished?
>
Senior: Translation can be staged, but the architecture must allow it from the start. Hard-coded labels, concatenated sentences and fixed widths are expensive to reverse.

43. Telemetry that helps without surveilling administrators

The page needs operational and product evidence, but it handles identities and administrative actions. Collect the minimum.

Operational signals:

  • user-query latency, error category and result-count band;
  • cancelled/superseded request count;
  • bulk-operation queue age, duration and outcome counts;
  • export duration, size band and failure reason;
  • concurrency-conflict rate;
  • client rendering and interaction responsiveness;
  • unexpected permission rejection rate.
Use bounded categories in metrics. User ID, email, raw search text, operation ID and tenant ID are high-cardinality or sensitive and do not belong in metric labels. Put safe correlation identifiers in restricted logs/traces when needed.

Product analytics might ask whether filters are useful. Record which filter categories were applied and a coarse result-count band—not role/user names or free-text search. Document purpose and retention, honour consent/legal requirements, and restrict access.

Administrative commands need an audit trail distinct from diagnostic logs. Record actor identity, target scope, action, approved reason, time, request ID and result according to policy. Protect audit integrity and access. Do not duplicate full user profiles into every record.

Design supportability into the UI

On an unexpected failure, show a safe support reference derived from trace/correlation identity. Do not expose stack traces. The support runbook should locate:

  • the client navigation/query event;
  • the API trace;
  • the authorisation decision category;
  • the command/idempotency record;
  • the bulk job and per-target summary;
  • the audit event.
For privacy, support tools should reveal only what the operator's role and case assignment permit. “Support needs everything” is not least privilege.

Observe user impact

An API can report 99.9% success while administrators cannot complete work because menus lose focus or selection disappears. Combine server signals with browser journey monitoring and support feedback. Track the critical journey: open deep link, find user, perform an allowed action and receive confirmed outcome.

Alert on sustained impact, not every aborted search request. Cancellation due to newer typing is expected. A surge of 403 after a deployment may indicate a policy/client mismatch; a normal forbidden request is not a system outage.

44. Threat-model the page as a system

Walk one request from browser to database and back.

Spoofing and session misuse

Use established authentication, secure cookie/token handling, reauthentication for high-risk actions where policy requires, and protection against session fixation/theft. The client must not decide identity from editable storage.

Tampering

Validate IDs, filters, sort fields, versions and command bodies on the server. Use antiforgery protection for cookie-authenticated unsafe requests according to the framework architecture. Idempotency keys prevent duplicated intent; they do not authenticate it.

Repudiation

Audit high-impact changes with actor, target, reason and outcome. Synchronise clocks and protect audit access. A front-end console log is not an audit trail.

Information disclosure

Tenant-scope every query. Minimise DTO fields. Avoid raw personal data in URLs because browser history, referrers and logs may retain it. Protect exports, autocomplete results, caches, errors and telemetry.

Denial of service

Bound page size, filter complexity, exports and bulk targets. Rate-limit expensive operations by identity/tenant. Cancel obsolete work and optimise/search index common patterns. Do not let a client request an arbitrary 10-million-row page.

Elevation of privilege

Re-authorise every command against the actual target and current policy. Prevent self-escalation, protected-role modification and cross-tenant target substitution. Test APIs directly without the UI.

Threat modelling should produce concrete controls and tests. Keep it updated when adding a new action, export column, search field or support role.

45. Architecture and pull-request checklist

  • Can we state the page workflow and each component's responsibility?
  • Is server state separate from draft UI state?
  • Does URL state initialise the page without duplicate loading?
  • Are stale requests cancelled and ignored?
  • Is selection scope mathematically explicit and visible?
  • Do filter changes invalidate query-based selection?
  • Are bulk targets resolved and authorised on the server?
  • Are commands versioned, idempotent and auditable?
  • Does the UI handle partial success and uncertain response?
  • Are permissions server-enforced and presentation capabilities focused?
  • Does the table retain native semantics and keyboard operation?
  • Are loading, empty, failure, conflict and success states designed?
  • Are tenant and permission dimensions present in cache/query keys?
  • Is export a governed backend workflow at scale?
  • Do tests cover state algebra, contracts, accessibility and races?
  • Can support correlate an operation without exposing personal data?
If a component requires the whole page state and emits arbitrary mutations, its boundary is probably wrong. If the page contains every cell's markup and menu behaviour, its boundary is also wrong. Aim for explicit, feature-language contracts in both directions.

46. Mentoring exercises

Exercise one: draw the authority map

For view, invite, edit role, lock, disable and export, write the actor, resource, constraint, server policy, audit event and UI affordance. Identify one rule that cannot be represented by a global role.

Exercise two: implement selection algebra

Create pure functions for select, deselect, select-visible, select-all-matching, exclude and invalidate-on-query-change. Use generated sets to test that counts never become negative and excluded IDs behave consistently.

Exercise three: reproduce a race

Build a fake API where the first query waits longer than the second. Prove the old implementation displays stale results. Add cancellation and request identity, then prove the newest query wins.

Exercise four: keyboard journey

Without a pointer, open filtered user management, sort, select two users, inspect an action menu, cancel confirmation and navigate pages. Record every missing label, focus loss and ambiguous announcement.

Exercise five: bulk failure drill

Create an operation where some users become protected after submission and the network loses the initial response. Reconcile by request ID, display partial outcomes, preserve audit and offer safe retry only for eligible failures.

Exercise six: framework translation

Implement the same query/draft/selection state in Angular, React or Blazor. Keep contracts and tests framework-neutral where possible. Explain which code changed because of framework mechanics and which rules remained business/UI architecture.

47. Continue the learning path

Read Angular Modern Web Development, Full-Stack React, TypeScript and Node, or Blazor Web Development for a framework-specific implementation. Use Frontend Architecture Patterns for broader feature boundaries, CSS and Accessible UI for layout/semantics, HTTP and Web APIs for query and command contracts, and Web Security for authorisation, personal data and export threats.

The important cross-link is this: component architecture is not isolated from backend design. Selection semantics define command contracts. URL state defines query parsing. permissions define resource authorisation. loading and conflict states expose distributed-system behaviour. A professional UI makes those boundaries understandable to the user.


48. The approach I would carry into the build

When I design a complex UI like this, I do not begin by asking:

“How many components do I need?”

Instead, I ask:

“What responsibilities exist on this screen?”

The responsibilities are:

Application shell. Page orchestration. Filtering. Searching. Metrics. Data display. Selection. Bulk operations. Row operations. Pagination. Export. Permissions. Feedback. Layout.

Each responsibility deserves a home.

If one component owns too many responsibilities, it becomes hard to change.

If components are too tiny and artificial, the app becomes hard to navigate.

The art is balance.

A good component is not just small. A good component is understandable.

A good page is not empty. A good page coordinates the use case.

A good shared component is not business-aware. A good feature component speaks the language of the business.

For this User Management screen, the strongest architecture would feel like this:

AppShell owns the frame.
UserManagementPage owns the workflow.
FilterPanel owns filter editing.
SummaryCards display metrics.
UsersTable displays users.
SelectionToolbar handles selected-user actions.
Pagination controls page navigation.
RowActions expose per-user commands.
Shared UI components provide consistent design.
API/services provide data.
Permissions protect available actions.
Backend remains the source of truth.

That is the approach I would apply in React, Angular or Blazor.

React will express it with props, hooks and components. Angular will express it with inputs, outputs, services and observables. Blazor will express it with parameters, event callbacks and services.

But the architecture is the same.

If I leave you with one principle, it is this:

Complex UI is not made simple by reducing features. It is made simple by giving every feature a clear responsibility and a clear home.

That is how I design screens that can grow beyond version one while remaining understandable to the next developer who works on them.

Definition of done for this screen

The feature is done when an authorised administrator can open a deep link, understand the active query, find the correct user, perform an allowed action and receive an unambiguous server-confirmed outcome. Refresh, Back/Forward, rapid filtering, pagination and narrow layouts must preserve the documented state rules. A keyboard user must complete the same critical journey with visible focus and meaningful announcements.

The server must prove tenant isolation, resource authorisation, concurrency handling and idempotency independently of the UI. Bulk selection must have explicit scope, survive uncertain delivery safely and report partial results honestly. Exports must be authorised, bounded, audited, protected and expired according to policy.

The evidence is broader than a screenshot: pure tests for query and selection transitions; component tests for semantics and focus; API integration tests for permissions and conflicts; browser tests for deep links, races and keyboard operation; representative performance measurements; a threat-model review; and telemetry/runbooks that let support reconcile one operation without reading personal data unnecessarily.

Finally, ask a developer unfamiliar with the implementation to trace a filter change and a bulk command through URL, page state, component event, API contract, authorisation, persistence, audit and feedback. If ownership becomes ambiguous, refine the boundary. Maintainability is demonstrated when the next person can predict where a change belongs and which tests prove it safe. That clarity is the real measure of successful component architecture in a long-lived product.

Applied In

The thinking in this article has been applied throughout my enterprise portfolio, where architecture, workflows, permissions, notifications, reporting and modular design are all built around real business operations rather than isolated technical features.

View BuildEstate Pro →
Afzal Ahmed

Faz Ahmed

Senior Full Stack Engineer & Technical Lead

A hands-on engineer with 15+ years in commercial software. I publish what I am studying, revising and testing so visitors can see both established experience and learning still in progress.

How would you approach this problem? I'd love to hear your thoughts or continue the discussion.

Connect on LinkedIn →