Showing posts with label Zod Validation. Show all posts
Showing posts with label Zod Validation. Show all posts

Monday, 26 January 2026

React 19: The Practical Difference Between Hooks and Components (With TypeScript and Azure Integration)

The difference between react hooks and components matters because it defines how you separate logic from presentation. Problem: teams mix stateful logic directly inside UI and struggle to test, reuse, and scale. Solution: put data fetching, validation, and side effects in reusable hooks; keep rendering in lean components. Value: cleaner architecture, easier testing, fewer bugs, and production-ready integration with Azure using least privilege.

The Problem

Developers often blur the line between where logic lives (hooks) and where UI renders (components). This leads to duplicated code, tangled effects, and UI tests that are slow and brittle. We need a clear pattern: hooks encapsulate logic and I/O; components focus on layout and accessibility.

Prerequisites

Node.js v20+, TypeScript 5+ with strict mode, React 19, TanStack Query v5+, Zod v3+, Azure Functions Core Tools v4+, .NET 8 SDK, Azure CLI (or azd), and a browser-compatible fetch API.

The Solution (Step-by-Step)

1) Define strict runtime and compile-time types

// src/schemas/user.ts
import { z } from "zod";

// Zod schema for runtime validation and safe parsing
export const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().min(1),
});

export const UsersSchema = z.array(UserSchema);

export type User = z.infer<typeof UserSchema>;

2) Create a focused hook: logic, data fetching, and validation

// src/hooks/useUsers.ts
import { useMemo } from "react";
import { useQuery, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { UsersSchema, type User } from "../schemas/user";

// Discriminated union for explicit UI states
export type UsersState =
  | { status: "loading" }
  | { status: "error"; error: string }
  | { status: "success"; data: ReadonlyArray<User> };

// Fetch function with runtime validation and descriptive errors
async function fetchUsers(): Promise<ReadonlyArray<User>> {
  const res = await fetch("/api/users", { headers: { "accept": "application/json" } });
  if (!res.ok) {
    // Include status for observability; avoid leaking server internals
    throw new Error(`Request failed: ${res.status}`);
  }
  const json = await res.json();
  // Validate and coerce; throws if shape is wrong
  return UsersSchema.parse(json);
}

export function useUsers(): UsersState {
  const { data, error, status } = useQuery({
    queryKey: ["users"],
    queryFn: fetchUsers,
    staleTime: 60_000, // cache for 1 minute
    retry: 2,          // conservative retry policy
  });

  // Map TanStack Query status to a strict discriminated union for the UI
  return useMemo((): UsersState => {
    if (status === "pending") return { status: "loading" };
    if (status === "error") return { status: "error", error: (error as Error).message };
    // At this point data is defined and validated by Zod
    return { status: "success", data: data ?? [] };
  }, [status, error, data]);
}

// Optional: provide a QueryClient at the app root (copy-paste ready)
export const queryClient = new QueryClient();

// In your app root (e.g., src/main.tsx):
// import { createRoot } from "react-dom/client";
// import { QueryClientProvider } from "@tanstack/react-query";
// import { queryClient } from "./hooks/useUsers";
// import { App } from "./App";
// createRoot(document.getElementById("root")!).render(
//   <QueryClientProvider client={queryClient}>
//     <App />
//   </QueryClientProvider>
// );

3) Keep components presentational and accessible

// src/components/UsersList.tsx
import React from "react";
import { useUsers } from "../hooks/useUsers";

// Functional component focuses on rendering and accessibility
export function UsersList(): JSX.Element {
  const state = useUsers();

  if (state.status === "loading") {
    // Keep loading states lightweight and non-blocking
    return <p role="status" aria-live="polite">Loading users...</p>;
  }

  if (state.status === "error") {
    // Display a user-friendly message without revealing internals
    return <p role="alert">Could not load users. Please try again.</p>;
  }

  // Success path: minimal, semantic markup
  return (
    <ul aria-label="Users">
      {state.data.map(u => (
        <li key={u.id}>{u.name} ({u.email})</li>
      ))}
    </ul>
  );
}

4) Optional Azure back end: least-privilege, Managed Identity

This example shows an Azure Functions .NET 8 HTTP API that returns users. It authenticates to Azure Cosmos DB using DefaultAzureCredential and a system-assigned Managed Identity, avoiding connection strings. Assign only the necessary RBAC role.

// FunctionApp/Program.cs (.NET 8 isolated worker)
using Azure.Identity; // DefaultAzureCredential
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using Azure.Core;
using Azure.Cosmos; // Azure.Data.Cosmos alternative for .NET SDK

var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults()
    .ConfigureServices(services =>
    {
        // Use managed identity via DefaultAzureCredential
        services.AddSingleton<TokenCredential>(_ => new DefaultAzureCredential());

        services.AddSingleton<CosmosClient>(sp =>
        {
            var credential = sp.GetRequiredService<TokenCredential>();
            // Endpoint from configuration (no keys). Use App Settings.
            var endpoint = Environment.GetEnvironmentVariable("COSMOS_ENDPOINT")!;
            return new CosmosClient(endpoint, credential);
        });
    })
    .Build();

await host.RunAsync();

// FunctionApp/GetUsers.cs
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using System.Net;
using Azure.Cosmos;
using System.Text.Json;

namespace FunctionApp;

public class GetUsers(CosmosClient cosmos)
{
    // HTTP-triggered function returning JSON users
    [Function("GetUsers")] 
    public async Task<HttpResponseData> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "users")] HttpRequestData req)
    {
        var db = cosmos.GetDatabase("app");
        var container = db.GetContainer("users");

        var iterator = container.GetItemQueryIterator<UserDoc>("SELECT c.id, c.email, c.name FROM c");
        var results = new List<UserDoc>();
        while (iterator.HasMoreResults)
        {
            var page = await iterator.ReadNextAsync();
            results.AddRange(page);
        }

        var res = req.CreateResponse(HttpStatusCode.OK);
        await res.WriteStringAsync(JsonSerializer.Serialize(results));
        res.Headers.Add("Content-Type", "application/json");
        return res;
    }
}

public record UserDoc(string id, string email, string name);

Required RBAC (principle of least privilege): Assign the Function App's system-assigned identity the Cosmos DB Built-in Data Reader role scoped to the specific database or container. Avoid account-level permissions.

5) Minimal IaC for role assignment (Azure Bicep)

// main.bicep: create role assignment for Function App's managed identity
param cosmosAccountId string
param databaseRid string // scope appropriately (e.g., database or container resource ID)
param functionPrincipalId string // Function App's system-assigned identity principalId

resource roleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
  scope: subscription()
  name: '00000000-0000-0000-0000-000000000001' // placeholder, replace with Cosmos DB Built-in Data Reader GUID
}

resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(functionPrincipalId, databaseRid, roleDefinition.name)
  scope: resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', cosmosAccountId, 'app')
  properties: {
    roleDefinitionId: roleDefinition.id
    principalId: functionPrincipalId
    principalType: 'ServicePrincipal'
  }
}

Note: Use azd to provision and configure environment variables like COSMOS_ENDPOINT. Never embed secrets or connection strings in code.

6) Wire up the React client to the Azure Function

// src/hooks/useUsers.ts (override the URL to your deployed Function App)
async function fetchUsers(): Promise<ReadonlyArray<User>> {
  const res = await fetch(import.meta.env.VITE_API_BASE + "/users", {
    headers: { accept: "application/json" },
    credentials: "include", // if using auth; otherwise omit
  });
  if (!res.ok) throw new Error(`Request failed: ${res.status}`);
  const json = await res.json();
  return UsersSchema.parse(json);
}

7) Testing hooks and components separately

// tests/useUsers.test.tsx
import { describe, it, expect } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { useUsers } from "../src/hooks/useUsers";

function wrapper({ children }: { children: React.ReactNode }) {
  const client = new QueryClient();
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

describe("useUsers", () => {
  it("returns success after fetching", async () => {
    global.fetch = async () => new Response(JSON.stringify([]), { status: 200 });
    const { result } = renderHook(() => useUsers(), { wrapper });

    await waitFor(() => {
      expect(result.current.status).toBe("success");
    });
  });
});
// tests/UsersList.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { UsersList } from "../src/components/UsersList";

function renderWithQuery(ui: React.ReactElement) {
  const client = new QueryClient();
  return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}

describe("UsersList", () => {
  it("renders loading state", () => {
    renderWithQuery(<UsersList />);
    expect(screen.getByRole("status")).toHaveTextContent(/loading/i);
  });
});

Best Practices & Security

Hooks own side effects; components remain pure and predictable. Validate all external data with Zod and use strict TypeScript to catch issues at compile time. For Azure, prefer Managed Identity with DefaultAzureCredential and apply the smallest RBAC scope required. Keep API base URLs and configuration in environment variables managed by azd or your CI/CD system, not in source. For database reads with Entity Framework in .NET APIs, use AsNoTracking() to avoid unnecessary change tracking.

Summary

Hooks encapsulate reusable logic, I/O, and validation, while components render UI and stay testable. Strong typing with Zod and discriminated unions keeps state explicit and safe. Azure integration is secure with Managed Identity, least-privilege RBAC, and IaC via azd or Bicep.

Friday, 16 January 2026

Implementing PnP People Picker in React for SPFx: A Ready-to-Use Example with Strict TypeScript and Zod

The primary keyword pnp people picker control in react for SPfx with example sets the scope: implement a production-grade People Picker in a SharePoint Framework (SPFx) web part using React, strict TypeScript, and Zod validation. Why this matters: avoid vague selections, respect tenant boundaries and theming, and ship a fast, accessible control that your security team can approve.

The Problem

Developers often wire up People Picker quickly, then face issues with invalid selections, poor performance in large tenants, theming mismatches, and missing API permissions. The goal is a robust People Picker that validates data, performs well, and aligns with SPFx and Microsoft 365 security constraints.

Prerequisites

  • Node.js v20+
  • SPFx v1.18+ (React and TypeScript template)
  • @pnp/spfx-controls-react v3.23.0+ (PeoplePicker)
  • TypeScript strict mode enabled ("strict": true)
  • Zod v3.23.8+ for schema validation
  • Tenant admin rights to approve Microsoft Graph permissions for the package

The Solution (Step-by-Step)

1) Install dependencies and pin versions

npm install @pnp/spfx-controls-react@3.23.0 zod@3.23.8

Recommendation: pin versions to prevent accidental breaking changes in builds.

2) Configure delegated permissions (least privilege)

In config/package-solution.json, request the minimum Graph scopes needed to resolve people:

{
  "solution": {
    "webApiPermissionRequests": [
      { "resource": "Microsoft Graph", "scope": "User.ReadBasic.All" },
      { "resource": "Microsoft Graph", "scope": "People.Read" }
    ]
  }
}

After packaging and deploying, a tenant admin must approve these scopes. These are delegated permissions tied to the current user; no secrets or app-only access are required for the People Picker scenario.

3) Implement a strict, validated People Picker component

/* PeoplePickerField.tsx */
import * as React from "react";
import { useCallback, useMemo, useState } from "react";
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { PeoplePicker, PrincipalType } from "@pnp/spfx-controls-react/lib/PeoplePicker";
import { z } from "zod";

// Define the shape we accept from People Picker selections
// The control returns IPrincipal-like objects; we validate subset we rely upon.
const PersonSchema = z.object({
  id: z.union([z.string(), z.number()]), // Graph or SP ID can be number or string
  secondaryText: z.string().nullable().optional(), // usually email or subtitle
  text: z.string().min(1), // display name
});

const SelectedPeopleSchema = z.array(PersonSchema).max(5); // enforce cardinality

export type ValidPerson = z.infer<typeof PersonSchema>;

export interface PeoplePickerFieldProps {
  context: WebPartContext; // SPFx context to ensure tenant and theme alignment
  label?: string;
  required?: boolean;
  maxPeople?: number; // override default of 5
  onChange: (people: ValidPerson[]) => void; // emits validated data only
}

// Memoized to avoid unnecessary re-renders in large forms
const PeoplePickerField: React.FC<PeoplePickerFieldProps> = ({
  context,
  label = "Assign to",
  required = false,
  maxPeople = 5,
  onChange,
}) => {
  // Internal state to show validation feedback
  const [error, setError] = useState<string | null>(null);

  // Enforce hard cap
  const personSelectionLimit = useMemo(() => Math.min(Math.max(1, maxPeople), 25), [maxPeople]);

  // Convert PeoplePicker selections through Zod
  const handleChange = useCallback((items: unknown[]) => {
    // PeoplePicker sends unknown shape; validate strictly before emitting
    const parsed = SelectedPeopleSchema.safeParse(items);
    if (!parsed.success) {
      setError("Invalid selection. Please choose valid users only.");
      onChange([]);
      return;
    }

    // Optional business rule: ensure each user has an email-like secondaryText
    const withEmail = parsed.data.filter(p => (p.secondaryText ?? "").includes("@"));
    if (withEmail.length !== parsed.data.length) {
      setError("Some selections are missing a valid email.");
      onChange([]);
      return;
    }

    setError(null);
    onChange(parsed.data);
  }, [onChange]);

  return (
    <div>
      <label>{label}{required ? " *" : ""}</label>
      {/**
       * PeoplePicker respects SPFx theme through provided context.
       * Use PrincipalType to limit search to users only, avoiding groups for clarity.
       */}
      <PeoplePicker
        context={context}
        titleText={label}
        personSelectionLimit={personSelectionLimit}
        ensureUser={true} // resolves users to the site collection to avoid auth issues
        showHiddenInUI={false}
        principalTypes={[PrincipalType.User]}
        resolveDelay={300} // debounce for performance in large tenants
        onChange={handleChange}
        required={required}
      />

      {/** Live region for accessibility */}
      <div aria-live="polite">{error ? error : ""}</div>
    </div>
  );
};

export default React.memo(PeoplePickerField);

Notes: resolveDelay reduces repeated queries while typing. principalTypes avoids unnecessary group matches unless you require them.

4) Use the field in a web part with validated submit

/* MyWebPartComponent.tsx */
import * as React from "react";
import { useCallback, useState } from "react";
import { WebPartContext } from "@microsoft/sp-webpart-base";
import PeoplePickerField, { ValidPerson } from "./PeoplePickerField";

interface MyWebPartProps { context: WebPartContext; }

const MyWebPartComponent: React.FC<MyWebPartProps> = ({ context }) => {
  const [assignees, setAssignees] = useState<ValidPerson[]>([]);
  const [submitStatus, setSubmitStatus] = useState<"idle" | "saving" | "success" | "error">("idle");

  const handlePeopleChange = useCallback((people: ValidPerson[]) => setAssignees(people), []);

  const handleSubmit = useCallback(async () => {
    try {
      setSubmitStatus("saving");
      // Example: persist only the IDs or emails to a list/Graph to avoid storing PII redundantly
      const payload = assignees.map(p => ({ id: String(p.id), email: p.secondaryText }));
      // TODO: call a secure API (e.g., SPHttpClient to SharePoint list) using current user's context
      await new Promise(r => setTimeout(r, 600)); // simulate network
      setSubmitStatus("success");
    } catch {
      setSubmitStatus("error");
    }
  }, [assignees]);

  return (
    <div>
      <h3>Create Task</h3>
      <PeoplePickerField
        context={context}
        label="Assignees"
        required={true}
        maxPeople={3}
        onChange={handlePeopleChange}
      />
      <button onClick={handleSubmit} disabled={assignees.length === 0 || submitStatus === "saving"}>
        Save
      </button>
      <div aria-live="polite">{submitStatus === "saving" ? "Saving..." : ""}</div>
      <div aria-live="polite">{submitStatus === "success" ? "Saved" : ""}</div>
      <div aria-live="polite">{submitStatus === "error" ? "Save failed" : ""}</div>
    </div>
  );
};

export default MyWebPartComponent;

5) Authentication in SPFx context

SPFx provides delegated authentication via the current user. For Microsoft Graph calls, use MSGraphClientFactory; for SharePoint calls, use SPHttpClient. You do not need to store tokens; SPFx handles tokens and consent. Avoid manual token acquisition unless implementing advanced scenarios.

6) Minimal test to validate the component contract

// PeoplePickerField.test.tsx
import React from "react";
import { render } from "@testing-library/react";
import PeoplePickerField from "./PeoplePickerField";

// Mock SPFx WebPartContext minimally for the control; provide shape your test runner needs
const mockContext = {} as unknown as any; // In real tests, provide a proper mock of the context APIs used by the control

test("renders label and enforces required", () => {
  const { getByText } = render(
    <PeoplePickerField context={mockContext} label="Assignees" required onChange={() => {}} />
  );
  expect(getByText(/Assignees/)).toBeTruthy();
});

Note: In integration tests, mount within an SPFx test harness or mock the PeoplePicker dependency. For unit tests, focus on validation logic paths invoked by onChange.

Best Practices & Security

  • Least privilege permissions. Request only User.ReadBasic.All and People.Read for resolving users. Do not request write scopes unless necessary.
  • Azure RBAC and Microsoft 365 roles. This scenario uses delegated permissions within Microsoft 365; no Azure subscription RBAC role is required. Users need a valid SharePoint license and access to the site. Tenant admin must approve Graph scopes. For directory-read scenarios beyond basics, Directory Readers role may be required by policy.
  • PII hygiene. Persist only identifiers (e.g., user IDs or emails) rather than full profiles. Avoid logging personal data. Mask PII in telemetry.
  • Performance. Use resolveDelay to debounce search. Limit personSelectionLimit to a realistic value (e.g., 3–5). Memoize the field (React.memo) and callbacks (useCallback) to reduce re-renders in complex forms.
  • Accessibility. Provide aria-live regions for validation and submit status. Ensure color contrast via SPFx theming; the PeoplePicker uses SPFx theme tokens when context is provided.
  • Theming. Always pass the SPFx context to ensure the control inherits the current site theme.
  • Error resilience. Wrap parent forms with an error boundary to display a fallback UI if a child component throws.
  • Versioning. Pin dependency versions in package.json to avoid unexpected changes. Regularly update to the latest stable to receive security fixes.
  • No server-side tech references here. Entity Framework patterns such as AsNoTracking are not applicable in SPFx client-side code.

Example package.json pins

{
  "dependencies": {
    "@pnp/spfx-controls-react": "3.23.0",
    "zod": "3.23.8"
  }
}

Optional: Error boundary pattern

import React from "react";

class ErrorBoundary extends React.Component<{}, { hasError: boolean }> {
  constructor(props: {}) {
    super(props);
    this.state = { hasError: false };
  }
  static getDerivedStateFromError() { return { hasError: true }; }
  render() { return this.state.hasError ? <div>Something went wrong.</div> : this.props.children; }
}

export default ErrorBoundary;

Wrap your form: ErrorBoundary around MyWebPartComponent to ensure a graceful fallback.

Summary

  • Implemented a strict, validated People Picker for SPFx with React, Zod, and tenant-aware theming via context.
  • Applied least privilege delegated permissions with admin consent, clear performance tuning, and accessibility patterns.
  • Hardened production readiness through validation-first design, memoization, testing hooks, and pinned dependencies.