Modern Front-End Architecture: Components, State, APIs, and the Accessibility Task You Can't Skip

30 min read
Front-EndReactAngularEngineering

Front-end job requirements pack a lot into a single bullet point or two. Reading them for the first time, I remember thinking — that's an entire career compressed into two sentences, and I don't know if I should apply.

But here's the thing: those aren't separate skills — they're layers of the same system. Components give you structure. State management gives you predictability. API integration gives you data. Accessibility gives you users who can actually use what you built. And of course, the UI framework or library of choice gives you the abstraction, versatility, and the limitations that come with it. Skip any layer and the whole thing wobbles.

This post breaks down each of those layers with practical code in both React and Angular, the two I've worked with most recently — not because one is better, but because understanding both makes you a better engineer regardless of which one your next team uses.


Component-Based Architecture

Before components, front-end code was a tangled mess of jQuery selectors, global state mutations, and HTML templates that knew way too much about business logic. I know because I lived it — and I don't miss a single $(document).ready().

Component-based architecture solves this by breaking the UI into isolated, reusable, composable pieces. Each component:

  • Encapsulates its own markup, styles, and behavior
  • Receives data through a well-defined interface (props/inputs)
  • Communicates up through events or callbacks
  • Composes into larger UI structures like building blocks

The mental model is simple: every piece of UI is a function that takes data in and renders output. The implementation details differ between frameworks, but the principle is universal.

React: Functions All the Way Down

React's component model is deliberately minimal. A component is a function that returns JSX. Props flow down. Events bubble up through callbacks. There's no ceremony, no decorators, no module system to register with.

interface UserCardProps {
  name: string;
  role: string;
  avatarUrl: string;
  onContact: (name: string) => void;
}

function UserCard({ name, role, avatarUrl, onContact }: UserCardProps) {
  return (
    <article className="user-card">
      <img src={avatarUrl} alt={`${name}'s avatar`} />
      <div>
        <h3>{name}</h3>
        <p>{role}</p>
        <button onClick={() => onContact(name)}>
          Contact
        </button>
      </div>
    </article>
  );
}

That's it. No class inheritance, no lifecycle method sprawl. The component is predictable — same props in, same output out. Composition happens naturally:

function TeamGrid({ members }: { members: User[] }) {
  return (
    <section className="team-grid">
      {members.map((member) => (
        <UserCard
          key={member.id}
          name={member.name}
          role={member.role}
          avatarUrl={member.avatarUrl}
          onContact={handleContact}
        />
      ))}
    </section>
  );
}

Angular: Decorators, Templates, and Explicit Contracts

Angular's component model is more structured. Components are TypeScript classes decorated with metadata that tells the framework how to wire them up. The template lives separately (or inline), and data flows through signal-based input() and output() functions — the modern API that replaces the older @Input()/@Output() decorators.

import { Component, input, output } from '@angular/core';

@Component({
  selector: 'app-user-card',
  template: `
    <article class="user-card">
      <img [src]="avatarUrl()" [alt]="name() + '\\'s avatar'" />
      <div>
        <h3>{{ name() }}</h3>
        <p>{{ role() }}</p>
        <button (click)="contact.emit(name())">
          Contact
        </button>
      </div>
    </article>
  `,
})
export class UserCardComponent {
  name = input.required<string>();
  role = input.required<string>();
  avatarUrl = input.required<string>();
  contact = output<string>();
}

And the parent composing it:

import { Component, input } from '@angular/core';

@Component({
  selector: 'app-team-grid',
  imports: [UserCardComponent],
  template: `
    <section class="team-grid">
      @for (member of members(); track member.id) {
        <app-user-card
          [name]="member.name"
          [role]="member.role"
          [avatarUrl]="member.avatarUrl"
          (contact)="handleContact($event)"
        />
      }
    </section>
  `,
})
export class TeamGridComponent {
  members = input.required<User[]>();

  handleContact(name: string): void {
    // handle contact logic
  }
}

The Trade-Off

React gives you freedom and minimal boilerplate — at the cost of needing to establish your own conventions. Angular gives you structure and explicit contracts — at the cost of verbosity and a steeper learning curve. Neither is wrong. The "right" choice depends on your team's discipline and the project's scale (more on this at the end).


State Management

State management is where front-end applications go from "demo" to "production" — and where most teams over-engineer on day one. I've personally set up Redux with full action creators, reducers, middleware, and selectors for an app that had exactly three pieces of global state. Don't be me.

The golden rule: keep state as local as possible, and only lift it when you have a concrete reason.

The Spectrum of State

Not all state is equal. Understanding what you're dealing with determines which tool to reach for:

State Type Examples Typical Scope
UI state Modal open/closed, active tab, form input values Component-local
Server state Fetched data, pagination cursors, cache Shared (with caching layer)
App state Auth status, user preferences, theme Global
URL state Current route, query params, filters Router

Most "state management" problems are actually server state problems disguised as global state. If you find yourself syncing fetched data into a global store, you probably need a caching layer (TanStack Query, SWR) instead of Redux.

React: The Escalation Path

React gives you primitives that scale from simple to complex:

Level 1 — Local state with useState:

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

Level 2 — Complex local state with useReducer:

type CartAction =
  | { type: 'ADD_ITEM'; payload: Product }
  | { type: 'REMOVE_ITEM'; payload: string }
  | { type: 'CLEAR' };

interface CartState {
  items: CartItem[];
  total: number;
}

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find((i) => i.id === action.payload.id);
      const items = existing
        ? state.items.map((i) =>
            i.id === action.payload.id ? { ...i, qty: i.qty + 1 } : i
          )
        : [...state.items, { ...action.payload, qty: 1 }];
      return { items, total: items.reduce((sum, i) => sum + i.price * i.qty, 0) };
    }
    case 'REMOVE_ITEM':
      const items = state.items.filter((i) => i.id !== action.payload);
      return { items, total: items.reduce((sum, i) => sum + i.price * i.qty, 0) };
    case 'CLEAR':
      return { items: [], total: 0 };
  }
}

Level 3 — Shared state with Zustand (general preference over Redux Toolkit for most apps):

import { create } from 'zustand';

interface CartStore {
  items: CartItem[];
  total: number;
  addItem: (product: Product) => void;
  removeItem: (id: string) => void;
  clear: () => void;
}

const useCartStore = create<CartStore>((set) => ({
  items: [],
  total: 0,
  addItem: (product) =>
    set((state) => {
      const existing = state.items.find((i) => i.id === product.id);
      const items = existing
        ? state.items.map((i) =>
            i.id === product.id ? { ...i, qty: i.qty + 1 } : i
          )
        : [...state.items, { ...product, qty: 1 }];
      return { items, total: items.reduce((sum, i) => sum + i.price * i.qty, 0) };
    }),
  removeItem: (id) =>
    set((state) => {
      const items = state.items.filter((i) => i.id !== id);
      return { items, total: items.reduce((sum, i) => sum + i.price * i.qty, 0) };
    }),
  clear: () => set({ items: [], total: 0 }),
}));

// Usage in any component — no providers, no context wrappers
function CartSummary() {
  const { items, total } = useCartStore();

  return (
    <div>
      <p>{items.length} items — ${total.toFixed(2)}</p>
    </div>
  );
}

Zustand is ~1KB, has zero boilerplate, and doesn't require wrapping your app in providers. For most applications, it's all you need. Reach for Redux Toolkit when you need middleware, time-travel debugging, or your team already has Redux muscle memory.

Angular: Services and Signals as the State Layer

Angular's dependency injection system makes services the natural home for shared state. Signals are Angular's first-class reactive primitive for synchronous state — simpler than RxJS for most use cases:

import { Injectable, signal, computed } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class CartService {
  private readonly items = signal<CartItem[]>([]);

  readonly itemsList = this.items.asReadonly();
  readonly total = computed(() =>
    this.items().reduce((sum, i) => sum + i.price * i.qty, 0)
  );
  readonly count = computed(() => this.items().length);

  addItem(product: Product): void {
    this.items.update((current) => {
      const existing = current.find((i) => i.id === product.id);
      return existing
        ? current.map((i) =>
            i.id === product.id ? { ...i, qty: i.qty + 1 } : i
          )
        : [...current, { ...product, qty: 1 }];
    });
  }

  removeItem(id: string): void {
    this.items.update((current) => current.filter((i) => i.id !== id));
  }

  clear(): void {
    this.items.set([]);
  }
}
import { Component, inject } from '@angular/core';
import { CurrencyPipe } from '@angular/common';

@Component({
  selector: 'app-cart-summary',
  imports: [CurrencyPipe],
  template: `
    <div>
      <p>{{ cart.count() }} items — {{ cart.total() | currency }}</p>
    </div>
  `,
})
export class CartSummaryComponent {
  protected readonly cart = inject(CartService);
}

Signals integrate directly into templates without AsyncPipe — Angular's change detection reads them automatically. For enterprise-scale apps with complex async flows (WebSocket streams, debounced search, multi-source coordination), NgRx or RxJS-based services are still the right tool. But for synchronous state like a cart, signals are simpler and more performant.

When to Reach for Global State

Ask yourself these questions before installing a state management library:

  1. Does more than one unrelated component need this data? If not, keep it local.
  2. Does it need to survive navigation? If not, component state is fine.
  3. Is it server data? If yes, use a caching layer (TanStack Query / Angular's HttpClient with a service cache), not a global store.
  4. Would prop drilling through 4+ levels make the code unreadable? Now consider lifting to a store.

If you answered "no" to all four, you don't need global state. You need a useState or a service.


RESTful API Integration

Every front-end application eventually needs to talk to a server. The patterns for doing this well haven't changed much conceptually — you always need to handle loading, success, and error states — but the ergonomics have improved dramatically.

The Three States of Every API Call

Every network request puts your UI in one of three states:

[Idle] → trigger → [Loading] → response → [Success | Error]

Forgetting to handle any of these creates a broken experience. The user clicks a button and... nothing happens (forgot loading state). Or they see stale data (forgot error state). Or the page crashes (forgot null check on the response).

React: Custom Hooks and TanStack Query

The manual approach — a custom hook:

function usePosts() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    async function fetchPosts() {
      try {
        setIsLoading(true);
        const response = await fetch('/api/posts', {
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }

        const data = await response.json();
        setPosts(data);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setIsLoading(false);
      }
    }

    fetchPosts();
    return () => controller.abort();
  }, []);

  return { posts, isLoading, error };
}

This works, but you end up reinventing caching, deduplication, refetching on window focus, optimistic updates, and pagination. For anything beyond a toy app, TanStack Query handles all of that:

import { useQuery } from '@tanstack/react-query';

async function fetchPosts(): Promise<Post[]> {
  const response = await fetch('/api/posts');
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }
  return response.json();
}

function PostList() {
  const { data: posts, isLoading, error } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
    retry: 2,
  });

  if (isLoading) return <LoadingSkeleton />;
  if (error) return <ErrorMessage message={error.message} />;

  return (
    <ul>
      {posts?.map((post) => (
        <li key={post.id}>
          <a href={`/posts/${post.slug}`}>{post.title}</a>
        </li>
      ))}
    </ul>
  );
}

TanStack Query eliminates entire categories of bugs: race conditions, stale closures, cache invalidation logic, and loading state management. If you're building a React app that fetches data, it's not optional — it's infrastructure.

Angular: HttpClient and RxJS

Angular's HttpClient returns observables, which compose naturally with RxJS operators for retry logic, error handling, and request transformation:

import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError, timer } from 'rxjs';
import { catchError, retry, map } from 'rxjs/operators';

interface ApiResponse<T> {
  data: T;
  meta: { requestId: string };
}

@Injectable({ providedIn: 'root' })
export class PostsService {
  private readonly http = inject(HttpClient);

  getPosts(): Observable<Post[]> {
    return this.http.get<ApiResponse<Post[]>>('/api/posts').pipe(
      map((response) => response.data),
      retry({ count: 2, delay: (_, i) => timer(2 ** i * 1000) }),
      catchError(this.handleError)
    );
  }

  private handleError(error: HttpErrorResponse): Observable<never> {
    let message = 'An unexpected error occurred';

    if (error.status === 0) {
      message = 'Network error — check your connection';
    } else if (error.status === 404) {
      message = 'Resource not found';
    } else if (error.status >= 500) {
      message = 'Server error — please try again later';
    }

    return throwError(() => new Error(message));
  }
}
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-post-list',
  imports: [LoadingSkeletonComponent],
  template: `
    @if (posts(); as posts) {
      <ul>
        @for (post of posts; track post.id) {
          <li>
            <a [href]="'/posts/' + post.slug">{{ post.title }}</a>
          </li>
        }
      </ul>
    } @else {
      <app-loading-skeleton />
    }
  `,
})
export class PostListComponent {
  posts = toSignal(inject(PostsService).getPosts());
}

Using toSignal() from @angular/core/rxjs-interop bridges the RxJS world (ideal for HTTP) into signals for the template — eliminating the need for AsyncPipe and its subscription management.

The RxJS approach shines when you need to compose multiple streams, handle race conditions with switchMap, or debounce user input (like search-as-you-type). The learning curve is steeper than async/await, but the declarative power is unmatched for complex async workflows.

Error Handling That Doesn't Lie to Users

Regardless of framework, your error handling should:

  1. Distinguish between network errors and server errors — "No internet" and "Server returned 500" require different user messaging.
  2. Provide actionable feedback — "Something went wrong" is useless. "Failed to load posts. Retry?" is helpful.
  3. Implement retry with backoff — Don't hammer a failing server. Exponential backoff with jitter is the standard.
  4. Cancel in-flight requests on unmount — Memory leaks from orphaned requests are subtle and insidious. Use AbortController in React or takeUntilDestroyed() in Angular.

A Note on Authentication in API Calls

Most real-world APIs require auth tokens on every request. How you attach them differs between ecosystems — and so does how much the framework helps you.

React gives you nothing built-in. You'll typically create a wrapper around fetch (or use an Axios instance) that reads the token from a secure, httpOnly cookie or an in-memory store and attaches it as an Authorization header. If you're using TanStack Query, your queryFn just calls that wrapper. It works, but it's on you to set it up and enforce it — nothing stops a teammate from calling raw fetch without the token.

Angular has a first-class solution: HTTP interceptors. An interceptor sits between every outgoing request and the server, automatically attaching headers, handling 401 refreshes, or redirecting to login. Once registered, it applies to every HttpClient call in the app — no one can accidentally skip it:

import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  const token = auth.getToken();

  if (token) {
    req = req.clone({
      setHeaders: { Authorization: `Bearer ${token}` },
    });
  }

  return next(req);
};

You register it when providing HttpClient — after that, every request goes through it automatically:

// app.config.ts
provideHttpClient(withInterceptors([authInterceptor]))

The key difference: Angular enforces the pattern at the framework level — if you use HttpClient, the interceptor runs. React gives you the freedom to build the same pattern, but consistency depends on team discipline. Both work; Angular just makes it harder to forget.

In most production apps, these tokens come from an OIDC provider (Auth0, Entra ID, Keycloak) using the Authorization Code + PKCE flow — the recommended standard for SPAs since the implicit flow was deprecated. The choice of token storage still matters: Bearer tokens in memory avoid CSRF entirely (browsers don't auto-attach Authorization headers cross-origin), while httpOnly cookies need CSRF protection but are immune to XSS token theft. Either way — never localStorage.


Accessible, Cross-Browser, Cross-Device Experiences

Accessibility is not a feature. It's not a nice-to-have. It's not something you "add later" (spoiler: you won't). It's the baseline for a usable product. And before you think "my users don't need this" — you don't know that. Temporary disabilities (broken arm), situational limitations (holding a baby, bright sunlight), and permanent disabilities all exist on the same spectrum.

I'll admit: early in my career, I treated accessibility as a checkbox. Sprinkle some aria-label attributes, run Lighthouse, call it a day. That's not accessibility — that's decoration on top of a broken foundation.

Semantic HTML: The 80% Solution

The single most impactful thing you can do for accessibility costs nothing: use the right HTML elements.

<!-- Bad: div soup with ARIA band-aids -->
<div class="nav" role="navigation">
  <div class="btn" role="button" tabindex="0" onclick="navigate()">Home</div>
</div>

<!-- Good: semantic HTML that works out of the box -->
<nav>
  <a href="/">Home</a>
</nav>

Semantic HTML gives you for free:

  • Keyboard navigation (links and buttons are focusable by default)
  • Screen reader announcements (a <nav> is announced as "navigation")
  • Browser built-in behaviors (forms submit on Enter, details/summary toggles)

The rule of thumb: if a native HTML element does what you need, use it. Only reach for ARIA when you're building something that doesn't exist natively (custom comboboxes, tab panels, tree views).

ARIA: When You Actually Need It

ARIA (Accessible Rich Internet Applications) fills the gap between native HTML and custom widgets. But it comes with a critical warning: bad ARIA is worse than no ARIA. Incorrectly applied ARIA attributes can confuse screen readers more than missing ones.

When you do need ARIA:

  • Live regions — Announcing dynamic content updates (toast notifications, form errors):

    <div role="alert" aria-live="assertive">
      Payment failed. Please check your card details.
    </div>
    
  • Custom widgets — Tab panels, comboboxes, tree views that have no native HTML equivalent:

    <div role="tablist" aria-label="Account settings">
      <button role="tab" id="tab-1" aria-selected="true" aria-controls="panel-1">Profile</button>
      <button role="tab" id="tab-2" aria-selected="false" aria-controls="panel-2">Security</button>
    </div>
    <div role="tabpanel" id="panel-1" aria-labelledby="tab-1">...</div>
    <div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>...</div>
    
  • State communication — Expanded/collapsed, loading, disabled states:

    <button aria-expanded="false" aria-controls="menu-content">Menu</button>
    

When you don't need ARIA:

  • <button> already has role="button" — don't add it again
  • <nav> already has role="navigation" — redundant
  • <input type="checkbox"> already communicates its state — aria-checked is unnecessary

Keyboard Navigation and Focus Management

Every interactive element must be operable via keyboard. This means:

  • Tab order follows the visual layout (don't use tabindex values greater than 0)
  • Focus is visible — never remove :focus outlines without providing an alternative
  • Modal dialogs trap focus — Tab shouldn't escape into content behind the modal
  • Skip links let users bypass repetitive navigation

Here's an accessible modal pattern in React:

import { useEffect, useRef } from 'react';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (isOpen && !dialog.open) {
      dialog.showModal();
    } else if (!isOpen && dialog.open) {
      dialog.close();
    }
  }, [isOpen]);

  return (
    <dialog
      ref={dialogRef}
      onClose={onClose}
      aria-labelledby="modal-title"
    >
      <header>
        <h2 id="modal-title">{title}</h2>
        <button onClick={onClose} aria-label="Close dialog">
          &times;
        </button>
      </header>
      <div>{children}</div>
    </dialog>
  );
}

A few things to note here:

  • The <dialog> is always rendered (never conditionally returned as null) so the ref stays stable and .showModal()/.close() work reliably.
  • The native <dialog> element handles focus trapping and Escape-to-close automatically when opened with .showModal() — no custom key handler or focus trap library needed.
  • The onClose event on <dialog> fires when the browser closes it (e.g., via Escape), keeping your state in sync without a redundant onKeyDown handler.

And the Angular equivalent:

import { Component, ElementRef, effect, input, output, viewChild } from '@angular/core';

@Component({
  selector: 'app-modal',
  template: `
    <dialog
      #dialog
      (close)="close.emit()"
      aria-labelledby="modal-title"
    >
      <header>
        <h2 id="modal-title">{{ title() }}</h2>
        <button (click)="close.emit()" aria-label="Close dialog">
          &times;
        </button>
      </header>
      <div>
        <ng-content />
      </div>
    </dialog>
  `,
})
export class ModalComponent {
  isOpen = input.required<boolean>();
  title = input.required<string>();
  close = output<void>();

  private dialog = viewChild.required<ElementRef<HTMLDialogElement>>('dialog');

  constructor() {
    effect(() => {
      const dialog = this.dialog().nativeElement;

      if (this.isOpen() && !dialog.open) {
        dialog.showModal();
      } else if (!this.isOpen() && dialog.open) {
        dialog.close();
      }
    });
  }
}

The signal-based input() / output() / viewChild() functions replace the older decorator API. The effect() runs whenever isOpen() changes, and since viewChild is also a signal, Angular guarantees the element is available — no setTimeout hack needed.

Responsive Design: CSS That Adapts

Responsive design isn't just "make it fit on a phone." It's about building layouts that adapt to any viewport, input method, and user preference. The modern CSS toolkit gives you everything you need:

Flexbox for one-dimensional layouts:

.card-row {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}

.card-row > * {
  flex: 1 1 300px; /* Grow, shrink, minimum 300px before wrapping */
}

CSS Grid for two-dimensional layouts:

.dashboard {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 1.5rem;
}

Container queries for component-level responsiveness:

.card-container {
  container-type: inline-size;
}

@container (min-width: 400px) {
  .card {
    flex-direction: row; /* Horizontal layout when container is wide enough */
  }
}

@container (max-width: 399px) {
  .card {
    flex-direction: column; /* Stack vertically in narrow containers */
  }
}

Container queries are a game-changer because they let components respond to their own container's size rather than the viewport. A card component that stacks vertically in a sidebar but goes horizontal in the main content area — without media queries or JavaScript.

Respecting user preferences:

/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

/* Dark mode */
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #1a1a2e;
    --text: #e0e0e0;
  }
}

/* High contrast */
@media (forced-colors: active) {
  .btn {
    border: 2px solid ButtonText;
  }
}

Cross-Browser: Progressive Enhancement, Not Browser Sniffing

The days of if (navigator.userAgent.includes('MSIE')) are mercifully behind us. Modern cross-browser development is about progressive enhancement and feature detection:

  1. Start with a baseline that works everywhere — semantic HTML, basic CSS
  2. Layer on enhancements for capable browsers — container queries, view transitions, CSS nesting
  3. Detect features, not browsers — use @supports in CSS:
/* Baseline: gap emulated with margins */
.flex-row > * + * {
  margin-left: 1rem;
}

/* Enhancement: native gap if supported */
@supports (gap: 1rem) {
  .flex-row {
    gap: 1rem;
  }
  .flex-row > * + * {
    margin-left: 0;
  }
}

Testing Accessibility

Automated testing catches ~30-40% of accessibility issues. The rest requires manual testing. A solid testing strategy includes:

Tool Catches Misses
axe-core (in CI) Missing alt text, color contrast, invalid ARIA Logical focus order, meaningful alt text
Lighthouse Performance + accessibility scores Context-specific issues
Screen reader (VoiceOver/NVDA) Actual user experience Nothing — this is the gold standard
Keyboard-only testing Focus traps, unreachable elements Screen reader announcements

Integrate axe-core into your test suite:

// React — with vitest and jest-axe
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';

expect.extend(toHaveNoViolations);

it('UserCard has no accessibility violations', async () => {
  const { container } = render(
    <UserCard name="Ada" role="Engineer" avatarUrl="/ada.jpg" onContact={() => {}} />
  );
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

React vs Angular: When to Reach for Which

After years of working with React and Angular across different teams and project scales, here's the honest comparison:

Dimension React Angular
What it is Library (UI layer only) Full framework (batteries included)
Bundle size (hello world) ~45KB gzipped ~65KB gzipped
Learning curve Gentle start, deep rabbit holes (state, routing, SSR are all separate decisions) Steep initially, but the path is clear (everything is prescribed)
Opinions Few — you choose your own stack Many — DI, signals, standalone components, decorators are the way
TypeScript Supported, popular, optional Required (and deeply integrated)
State management Choose your own adventure (Context, Zustand, Redux, Jotai...) Signals for sync state, RxJS for async streams, NgRx for Redux pattern
Templating JSX (JavaScript expressions in markup) Angular templates (built-in control flow: @if, @for)
Team scale Flexible — works for 1-person to large teams with strong conventions Excels at large teams — conventions are enforced by the framework
Ecosystem Massive, fragmented (quality varies wildly) Curated, integrated (smaller but consistent)
SSR / Hydration Next.js (with React Server Components), Remix Built-in (@angular/ssr)
Auth / HTTP security Roll your own (fetch wrapper, Axios interceptors) Built-in (HTTP interceptors, XSRF protection)

When I'd pick React

  • Small to medium teams where developer experience and iteration speed matter
  • Startups and prototypes where you need to ship fast and pivot often
  • Projects with strong conventions already (established style guides, architecture docs)
  • When you want flexibility to swap out pieces (routing, state, styling) independently
  • When hiring — the React talent pool is simply larger

When I'd pick Angular

  • Large enterprise teams where consistency across squads is critical
  • Long-lived applications where the opinionated structure pays dividends over years
  • Teams that benefit from guardrails — Angular's CLI, schematics, and conventions reduce bikeshedding
  • When you need everything out of the box — routing, forms, HTTP, i18n, testing utilities, all maintained by one team
  • Projects heavy on reactive streams — RxJS is a first-class citizen, and signals handle the synchronous side

The Honest Take

If you're preparing for an interview and they use React: emphasize hooks, composition, and your ability to make architectural decisions in a flexible ecosystem. If they use Angular: emphasize your understanding of dependency injection, signals, RxJS patterns, and how the framework's structure enables large-team productivity.

And if the job description says "React/Angular/Vue" — they probably care less about which specific framework you know and more about whether you understand the underlying principles: component composition, unidirectional data flow, reactive state, and separation of concerns. These translate across all of them.


Bringing It All Together

That single bullet point from the job description — "component-based architecture, state management, RESTful API integration, and delivering accessible, cross-browser/cross-device experiences" — isn't asking you to recite framework APIs. It's asking whether you understand how modern front-end applications are structured and why.

Components give you maintainable, testable units of UI. State management gives you predictable data flow without spaghetti. API integration connects your UI to the real world with proper error handling. And accessibility ensures that what you build actually works for everyone — not just developers with 4K monitors and perfect vision testing on localhost.

These aren't separate skills. They're one skill: building front-end applications that work.


What's your framework of choice, and what made you commit? Feel free to drop a comment.

Comments

Loading comments…

Leave a comment

50 characters remaining

1000 characters remaining