← Blog8 min read#team#members#react

Team Members UI in React: Invite, Role Select, Remove Member

Build a full team members UI in React - invite by email, pick a role, remove members - with real code and no-fluff patterns for SaaS dashboards.

developer laptop screen showing team dashboard UI code

Why Team Member UIs Are Harder Than They Look

You'd think it's just a list and a button. A table of names, an invite field, maybe a dropdown - how complex can it get? Turns out, quite a bit. The visible surface is simple. The state underneath - pending invites, optimistic removes, role changes that need to round-trip to the server before you commit the UI - that's where most developers get burned.

The patterns here apply whether you're wiring up a real Supabase backend or mocking everything in local state for a demo. In practice, the component shape stays identical either way, which is the whole point of keeping your UI layer honest about what it owns versus what the server owns.

Worth noting: most SaaS products ship this feature in year one, often under deadline pressure. The result is usually a pile of one-off state scattered across parent components, prop-drilled callbacks three levels deep, and a remove-member flow that doesn't disable the button during the API call. This article builds it right from the start - small enough to understand, complete enough to actually ship.

We'll build three pieces: an invite form, a member list with role select, and a remove flow with confirmation. Each is a self-contained component that you can drop into any dashboard layout. If you want the visual treatment handled for you, Empire UI ships ready-to-use SaaS dashboard components including team panels.

Data Shape and State Design

Before writing a single JSX tag, nail the data shape. Here's the Member type you'll pass around:

// types/team.ts
export type Role = 'owner' | 'admin' | 'member' | 'viewer';

export interface Member {
  id: string;
  email: string;
  name: string;
  avatarUrl?: string;
  role: Role;
  status: 'active' | 'pending';  // pending = invite sent, not accepted
  joinedAt: string;               // ISO date string
}

The status field is the one people forget. A pending invite still appears in the list - you want the invitee to know the email was sent - but pending members shouldn't be assignable as owners, and you'll style them differently (muted row, "Pending" badge). Conflating invited-but-not-joined with active members is how you end up with broken role logic six months later.

For component state, keep it flat. One members array, one loading set (member IDs currently being mutated), one error string. That's it. You don't need a reducer for this - React's useState with immutable updates is plenty for teams under a few hundred members.

const [members, setMembers] = useState<Member[]>(initialMembers);
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
const [inviteError, setInviteError] = useState<string | null>(null);

The Invite Form: Email Input Plus Role Select

The invite form is two fields and a button. Sounds trivial. But you need email validation, a controlled role dropdown defaulting to 'member', loading state while the API call is in flight, and an inline error if the email is already on the team. Let's build it properly.

// components/InviteForm.tsx
import { useState, FormEvent } from 'react';
import type { Role } from '../types/team';

const ROLES: Role[] = ['admin', 'member', 'viewer'];
// owners can't be invited - only the initial account holder is owner

interface InviteFormProps {
  onInvite: (email: string, role: Role) => Promise<void>;
  existingEmails: string[];
}

export function InviteForm({ onInvite, existingEmails }: InviteFormProps) {
  const [email, setEmail] = useState('');
  const [role, setRole] = useState<Role>('member');
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const validate = (val: string) => {
    if (!val.includes('@')) return 'Enter a valid email address';
    if (existingEmails.includes(val.toLowerCase()))
      return 'That person is already on the team';
    return null;
  };

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    const err = validate(email.trim());
    if (err) { setError(err); return; }
    setBusy(true);
    setError(null);
    try {
      await onInvite(email.trim().toLowerCase(), role);
      setEmail('');
    } catch (e: unknown) {
      setError(e instanceof Error ? e.message : 'Invite failed');
    } finally {
      setBusy(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="flex gap-2 items-start flex-wrap">
      <div className="flex flex-col gap-1">
        <input
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
          placeholder="colleague@company.com"
          className="px-3 py-2 rounded-lg border border-gray-200 text-sm w-64
                     focus:outline-none focus:ring-2 focus:ring-violet-500"
          aria-label="Email address to invite"
          disabled={busy}
        />
        {error && <p className="text-red-500 text-xs">{error}</p>}
      </div>

      <select
        value={role}
        onChange={e => setRole(e.target.value as Role)}
        disabled={busy}
        className="px-3 py-2 rounded-lg border border-gray-200 text-sm
                   focus:outline-none focus:ring-2 focus:ring-violet-500"
      >
        {ROLES.map(r => (
          <option key={r} value={r}>
            {r.charAt(0).toUpperCase() + r.slice(1)}
          </option>
        ))}
      </select>

      <button
        type="submit"
        disabled={busy || !email}
        className="px-4 py-2 bg-violet-600 text-white rounded-lg text-sm
                   disabled:opacity-50 hover:bg-violet-700 transition-colors"
      >
        {busy ? 'Sending…' : 'Send Invite'}
      </button>
    </form>
  );
}

Honestly, the most common mistake I see here is forgetting to .toLowerCase() on both the input and the existing emails array when checking for duplicates. Someone on your team will have their email stored as Alice@Company.com and the invite check will pass for alice@company.com. Case-normalize everything going in.

The disabled={busy || !email} on the submit button matters more than people realize. Without it, a slow connection means double submits and two pending invite rows. Cheap fix, big UX win.

The Member List: Rows, Role Dropdowns, Remove Buttons

The list component takes your members array and renders a row per person. Each row has an avatar (or initials fallback), name, email, a role select that fires immediately on change, and a remove button. The tricky part is per-row loading state - you want the specific row's controls to disable while its API call is in flight, not the whole table.

// components/MemberList.tsx
import type { Member, Role } from '../types/team';

const ROLE_LABELS: Record<Role, string> = {
  owner: 'Owner',
  admin: 'Admin',
  member: 'Member',
  viewer: 'Viewer',
};

function Initials({ name }: { name: string }) {
  const parts = name.trim().split(' ');
  const letters = parts.length >= 2
    ? parts[0][0] + parts[parts.length - 1][0]
    : parts[0].slice(0, 2);
  return (
    <div className="w-8 h-8 rounded-full bg-violet-100 text-violet-700
                    flex items-center justify-center text-xs font-semibold uppercase">
      {letters}
    </div>
  );
}

interface MemberListProps {
  members: Member[];
  loadingIds: Set<string>;
  currentUserId: string;
  onRoleChange: (memberId: string, newRole: Role) => Promise<void>;
  onRemove: (memberId: string) => void;  // opens confirm dialog
}

export function MemberList({
  members,
  loadingIds,
  currentUserId,
  onRoleChange,
  onRemove,
}: MemberListProps) {
  return (
    <ul className="divide-y divide-gray-100">
      {members.map(member => {
        const isBusy = loadingIds.has(member.id);
        const isSelf = member.id === currentUserId;
        const isOwner = member.role === 'owner';

        return (
          <li key={member.id}
              className={`flex items-center gap-3 py-3 ${
                member.status === 'pending' ? 'opacity-60' : ''
              }`}>

            {member.avatarUrl
              ? <img src={member.avatarUrl} alt="" className="w-8 h-8 rounded-full" />
              : <Initials name={member.name} />}

            <div className="flex-1 min-w-0">
              <p className="text-sm font-medium text-gray-900 truncate">
                {member.name}
                {isSelf && <span className="ml-2 text-xs text-gray-400">(you)</span>}
              </p>
              <p className="text-xs text-gray-500 truncate">{member.email}</p>
            </div>

            {member.status === 'pending' && (
              <span className="text-xs text-amber-600 bg-amber-50
                               px-2 py-0.5 rounded-full font-medium">
                Pending
              </span>
            )}

            <select
              value={member.role}
              onChange={e => onRoleChange(member.id, e.target.value as Role)}
              disabled={isBusy || isOwner || isSelf}
              className="text-xs border border-gray-200 rounded-md px-2 py-1
                         focus:outline-none focus:ring-2 focus:ring-violet-500
                         disabled:opacity-50 disabled:cursor-not-allowed"
            >
              {Object.entries(ROLE_LABELS).map(([val, label]) => (
                // owners stay owners - don't show it as selectable for others
                (val === 'owner' && member.role !== 'owner') ? null :
                <option key={val} value={val}>{label}</option>
              ))}
            </select>

            <button
              onClick={() => onRemove(member.id)}
              disabled={isBusy || isOwner || isSelf}
              className="text-xs text-red-500 hover:text-red-700
                         disabled:opacity-30 disabled:cursor-not-allowed
                         transition-colors px-2 py-1 rounded"
              aria-label={`Remove ${member.name}`}
            >
              Remove
            </button>
          </li>
        );
      })}
    </ul>
  );
}

Quick aside: disable the role dropdown for owners - you don't want a junior admin accidentally changing the owner's role to "viewer" and locking the account holder out. Same logic applies to the remove button. The isOwner guard on both controls is not optional.

One more thing - the isSelf check prevents users from removing themselves or demoting their own role. That might seem obvious but it's a support ticket waiting to happen if you skip it. Let the backend enforce it too, but don't make users discover the error only after the round-trip.

Remove Confirmation Dialog

Never remove a team member on first click. Always confirm. Here's a minimal confirm dialog that uses a memberId | null state to drive open/closed - no third-party modal library needed, though if you want a polished version with animations the Empire UI component library has a dialog component ready to go.

// components/RemoveConfirm.tsx
interface RemoveConfirmProps {
  member: { name: string; email: string } | null;
  busy: boolean;
  onConfirm: () => void;
  onCancel: () => void;
}

export function RemoveConfirm({ member, busy, onConfirm, onCancel }: RemoveConfirmProps) {
  if (!member) return null;
  return (
    <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
         onClick={onCancel}>
      <div className="bg-white rounded-xl shadow-xl p-6 max-w-sm w-full mx-4"
           onClick={e => e.stopPropagation()}>
        <h2 className="font-semibold text-gray-900 mb-1">Remove team member?</h2>
        <p className="text-sm text-gray-500 mb-4">
          <strong>{member.name}</strong> ({member.email}) will lose access immediately.
          They can be re-invited later.
        </p>
        <div className="flex gap-2 justify-end">
          <button
            onClick={onCancel}
            disabled={busy}
            className="px-4 py-2 text-sm rounded-lg border border-gray-200
                       hover:bg-gray-50 disabled:opacity-50"
          >
            Cancel
          </button>
          <button
            onClick={onConfirm}
            disabled={busy}
            className="px-4 py-2 text-sm rounded-lg bg-red-600 text-white
                       hover:bg-red-700 disabled:opacity-50 transition-colors"
          >
            {busy ? 'Removing…' : 'Remove member'}
          </button>
        </div>
      </div>
    </div>
  );
}

The onClick={onCancel} on the backdrop plus e.stopPropagation() on the panel is the 2-line pattern that replaces a whole modal library for simple dialogs. It works in React 18 without any portals too, though for production you'd want to render this into a portal to avoid z-index fights with sticky headers.

In practice, skipping the confirmation step costs you. Users fat-finger Remove constantly on mobile - especially on rows that are 40px tall or less. The dialog is worth the 20 lines.

Wiring It All Together in the Parent

Here's the parent TeamSettings component that owns state and glues the three pieces together. The API calls are stubbed - swap them for your actual fetch logic, Supabase client calls, or tRPC mutations.

// pages/settings/team.tsx  (or wherever your settings live)
import { useState } from 'react';
import { InviteForm } from '../../components/InviteForm';
import { MemberList } from '../../components/MemberList';
import { RemoveConfirm } from '../../components/RemoveConfirm';
import type { Member, Role } from '../../types/team';

const MOCK_MEMBERS: Member[] = [
  { id: '1', name: 'Alice Chen', email: 'alice@co.com',
    role: 'owner', status: 'active', joinedAt: '2025-01-10' },
  { id: '2', name: 'Bob Marsh', email: 'bob@co.com',
    role: 'admin', status: 'active', joinedAt: '2025-03-22' },
  { id: '3', name: 'Carol Diaz', email: 'carol@co.com',
    role: 'member', status: 'pending', joinedAt: '2026-08-14' },
];

export default function TeamSettings() {
  const [members, setMembers] = useState<Member[]>(MOCK_MEMBERS);
  const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
  const [removingId, setRemovingId] = useState<string | null>(null);

  const setLoading = (id: string, on: boolean) =>
    setLoadingIds(prev => {
      const next = new Set(prev);
      on ? next.add(id) : next.delete(id);
      return next;
    });

  const handleInvite = async (email: string, role: Role) => {
    // await api.post('/team/invite', { email, role });
    const newMember: Member = {
      id: crypto.randomUUID(),
      name: email.split('@')[0],
      email,
      role,
      status: 'pending',
      joinedAt: new Date().toISOString(),
    };
    setMembers(prev => [...prev, newMember]);
  };

  const handleRoleChange = async (memberId: string, newRole: Role) => {
    setLoading(memberId, true);
    try {
      // await api.patch(`/team/${memberId}`, { role: newRole });
      await new Promise(r => setTimeout(r, 600)); // simulate latency
      setMembers(prev =>
        prev.map(m => m.id === memberId ? { ...m, role: newRole } : m)
      );
    } finally {
      setLoading(memberId, false);
    }
  };

  const confirmRemove = async () => {
    if (!removingId) return;
    setLoading(removingId, true);
    try {
      // await api.delete(`/team/${removingId}`);
      await new Promise(r => setTimeout(r, 600));
      setMembers(prev => prev.filter(m => m.id !== removingId));
      setRemovingId(null);
    } finally {
      setLoading(removingId, false);
    }
  };

  const removingMember = removingId
    ? members.find(m => m.id === removingId) ?? null
    : null;

  return (
    <div className="max-w-2xl mx-auto py-10 px-4">
      <h1 className="text-xl font-semibold text-gray-900 mb-6">Team members</h1>

      <div className="mb-6">
        <p className="text-sm text-gray-600 mb-3">Invite a new member</p>
        <InviteForm
          onInvite={handleInvite}
          existingEmails={members.map(m => m.email.toLowerCase())}
        />
      </div>

      <MemberList
        members={members}
        loadingIds={loadingIds}
        currentUserId="1"         // replace with auth context
        onRoleChange={handleRoleChange}
        onRemove={setRemovingId}
      />

      <RemoveConfirm
        member={removingMember ?? null}
        busy={removingId ? loadingIds.has(removingId) : false}
        onConfirm={confirmRemove}
        onCancel={() => setRemovingId(null)}
      />
    </div>
  );
}

Look, the crypto.randomUUID() call for the optimistic new member ID is fine for the invite flow because the pending member can't be edited until the user accepts and the backend creates a real ID. But if your app allows editing pending invites, you'd want to resync the ID from the server response instead of keeping the local one.

That said, optimistic updates make the UI feel instant. For role changes in particular, users expect the dropdown to visually update the moment they pick a value - a 600ms spinner on a select feels broken compared to an instant update that rolls back on error. Swap the immediate setMembers update to happen before the await if you want that optimistic behavior. If you need form validation patterns for more complex settings screens, that guide covers react-hook-form integration in depth.

Accessibility, Empty States, and Polish

The component works. Now make it not embarrassing. Three things to add before you call it done.

Empty state: When members.length === 0 (unlikely but possible if you're building a fresh workspace flow), render a helpful empty message instead of an empty <ul>. Even one line - <p>No members yet. Invite someone above.</p> - is better than silence.

Keyboard navigation: The confirm dialog needs to trap focus while open. Without focus trapping, tabbing past the Cancel/Remove buttons drops keyboard focus back into the obscured background. Add onKeyDown={e => e.key === 'Escape' && onCancel()} to the dialog container. For a complete focus-trap implementation without pulling in a library, the focus management in React article covers the ref-based approach with 20 lines of code.

Screen reader announcements: When a member is successfully removed or a role changes, sighted users see the row disappear or the dropdown update. Screen reader users get nothing. Add a live region:

``tsx <div aria-live="polite" aria-atomic="true" className="sr-only"> {lastAction} </div> ` Set lastAction to something like "Carol Diaz removed from team"` after each mutation. Clear it after 3 seconds. Twenty lines, huge accessibility win. If you want the visual side polished too - hover states, avatar ring colors per role, dark-mode support - browse the Empire UI component library for patterns you can lift directly.

One more thing - sort your member list. Owners first, then admins, then members, then viewers, then pending. Users look for themselves and their admins first, and a consistent sort order means they find them without scanning the whole list. Add a sortPriority map and sort before rendering. That's it.

FAQ

How do I prevent a user from changing their own role in the team UI?

Check if the row's member ID matches the current user's ID and disable the role select if so. Always enforce the same rule server-side - the UI guard is for UX, not security.

Should I optimistically update the member list or wait for the API response?

For invites, wait - you need the server-generated ID and status. For role changes, optimistic updates feel better. Roll back on error with a toast notification explaining what failed.

How do I handle pending invites that expire?

Store an expiresAt timestamp on the invite and display a 'Resend' button instead of Remove when the invite is past expiry. The backend should reject accept attempts on expired tokens regardless.

Can I use this pattern with tRPC or React Query instead of raw fetch?

Yes - replace the await api.patch(...) stubs with your mutation calls. React Query's useMutation gives you isPending state per mutation, which maps cleanly to the loadingIds Set pattern shown here.

Free components in 41 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React UI Components Complete Reference: 60+ Patterns with CodePricing Table React Component: 3-Tier, Annual Toggle, HighlightBilling Page in React: Plan Selector, Invoice Table, Card UpdateTailwind Pricing Section: 3-Tier Layout with Annual Toggle