TL;DR: The Orchestration Pattern is a powerful way to manage complex interactions between components, API calls, and state updates in React. Instead of letting logic scatter across dozens of useEffect hooks and event handlers, you centralize it into a dedicated "orchestrator" component or hook. This approach makes your code more predictable, testable, and maintainable—especially in enterprise applications with complex workflows.
The Problem: When React Components Become Spaghetti
Let's be honest. We've all been there. You start building a feature—say, a multi-step checkout form. Initially, it's simple. A few inputs, a submit button.
But then requirements grow:
- "We need to validate the address against a third-party API."
- "If the user is a returning customer, pre-fetch their saved payment methods."
- "Apply discount codes, but only after shipping is calculated."
- "If payment fails, show a specific error and roll back the shipping selection."
Suddenly, your component looks like this:
const Checkout = () => {
const [step, setStep] = useState(1);
const [cart, setCart] = useState(null);
const [shipping, setShipping] = useState(null);
const [payment, setPayment] = useState(null);
const [discount, setDiscount] = useState(null);
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
useEffect(() => {
// Fetch cart on mount
}, []);
useEffect(() => {
// Recalculate shipping when address changes
}, [address]);
useEffect(() => {
// Apply discount when cart or code changes
}, [discountCode, cart]);
const handlePayment = async () => {
// Complex logic with multiple steps and error handling
};
// 300+ more lines of imperative, hard-to-follow code...
};
This is imperative spaghetti. The "what" (user wants to checkout) is buried in the "how" (fetch this, update that, call this API, show this error). It's hard to test, hard to debug, and even harder for new team members to understand.
Enter the Orchestration Pattern.
What Is the Orchestration Pattern in React?
Inspired by backend microservices architecture (where an orchestrator coordinates multiple services), the Orchestration Pattern in React applies the same principle: centralize complex workflow logic into a single coordinator.
Think of it like a movie director:
- Orchestrator (Director): Knows the script. Calls "Action!" to the camera team, tells the actor when to enter, signals the lighting crew.
- Components/APIs (Actors/Crew): Do one thing well. They don't know the full script—they just respond to commands.
In React terms, the orchestrator manages:
- The sequence of operations (API call A, then B, then C)
- Branching logic (if response X, do Y; else do Z)
- Error handling and compensation (if step 3 fails, roll back step 2)
- State transitions (loading → success → error)
- Side effect coordination (avoiding race conditions)
A Simple Orchestration Pattern Implementation
Let's refactor the checkout example using a custom hook as our orchestrator.
Step 1: Define the Orchestrator Hook
// hooks/useCheckoutOrchestrator.js
import { useReducer, useCallback } from 'react';
import { validateAddress } from '../api/address';
import { calculateShipping } from '../api/shipping';
import { applyDiscount } from '../api/discount';
import { processPayment } from '../api/payment';
// State machine for the checkout process
const initialState = {
status: 'idle', // idle, validating, calculating, paying, success, error
step: 1,
cart: null,
address: null,
shipping: null,
discount: null,
paymentResult: null,
error: null,
};
function checkoutReducer(state, action) {
switch (action.type) {
case 'SET_CART':
return { ...state, cart: action.payload };
case 'SET_ADDRESS':
return { ...state, address: action.payload };
case 'VALIDATION_START':
return { ...state, status: 'validating', error: null };
case 'VALIDATION_SUCCESS':
return { ...state, status: 'idle', step: 2 };
case 'VALIDATION_ERROR':
return { ...state, status: 'error', error: action.payload };
case 'SHIPPING_START':
return { ...state, status: 'calculating' };
case 'SHIPPING_SUCCESS':
return { ...state, status: 'idle', shipping: action.payload, step: 3 };
case 'PAYMENT_START':
return { ...state, status: 'paying' };
case 'PAYMENT_SUCCESS':
return { ...state, status: 'success', paymentResult: action.payload, step: 4 };
case 'PAYMENT_ERROR':
return { ...state, status: 'error', error: action.payload };
case 'RESET':
return initialState;
default:
return state;
}
}
export function useCheckoutOrchestrator() {
const [state, dispatch] = useReducer(checkoutReducer, initialState);
const setCart = useCallback((cart) => {
dispatch({ type: 'SET_CART', payload: cart });
}, []);
const setAddress = useCallback((address) => {
dispatch({ type: 'SET_ADDRESS', payload: address });
}, []);
// The orchestrator's main workflow
const validateAndProceed = useCallback(async (address) => {
dispatch({ type: 'VALIDATION_START' });
try {
// Step 1: Validate address
const isValid = await validateAddress(address);
if (!isValid) {
throw new Error('Invalid address format');
}
dispatch({ type: 'VALIDATION_SUCCESS' });
// Step 2: Calculate shipping based on validated address
dispatch({ type: 'SHIPPING_START' });
const shippingOptions = await calculateShipping(address, state.cart);
dispatch({ type: 'SHIPPING_SUCCESS', payload: shippingOptions });
} catch (error) {
dispatch({ type: 'VALIDATION_ERROR', payload: error.message });
}
}, [state.cart]);
const applyDiscountCode = useCallback(async (code) => {
if (!state.cart) return;
try {
const discount = await applyDiscount(code, state.cart);
dispatch({ type: 'SET_DISCOUNT', payload: discount });
// Recalculate shipping with discount applied
dispatch({ type: 'SHIPPING_START' });
const updatedShipping = await calculateShipping(state.address, state.cart, discount);
dispatch({ type: 'SHIPPING_SUCCESS', payload: updatedShipping });
} catch (error) {
dispatch({ type: 'SET_ERROR', payload: error.message });
}
}, [state.cart, state.address]);
const processPaymentAndComplete = useCallback(async (paymentDetails) => {
dispatch({ type: 'PAYMENT_START' });
try {
const result = await processPayment({
cart: state.cart,
shipping: state.shipping,
discount: state.discount,
paymentDetails,
});
dispatch({ type: 'PAYMENT_SUCCESS', payload: result });
// Optional: Navigate to success page
return result;
} catch (error) {
dispatch({ type: 'PAYMENT_ERROR', payload: error.message });
// Compensation logic: if payment fails, shipping selection remains
// but we might want to show a retry option
throw error;
}
}, [state.cart, state.shipping, state.discount]);
const reset = useCallback(() => {
dispatch({ type: 'RESET' });
}, []);
return {
// State
status: state.status,
step: state.step,
cart: state.cart,
shipping: state.shipping,
discount: state.discount,
error: state.error,
paymentResult: state.paymentResult,
// Actions (the public API of our orchestrator)
setCart,
setAddress,
validateAndProceed,
applyDiscountCode,
processPaymentAndComplete,
reset,
};
}
Step 2: Consume the Orchestrator in Components
Now your components become "dumb" presentational components that simply call the orchestrator's methods:
// CheckoutPage.jsx
import { useCheckoutOrchestrator } from '../hooks/useCheckoutOrchestrator';
import { AddressForm } from './AddressForm';
import { ShippingSelector } from './ShippingSelector';
import { PaymentForm } from './PaymentForm';
import { LoadingSpinner } from './LoadingSpinner';
import { ErrorAlert } from './ErrorAlert';
export const CheckoutPage = () => {
const {
status,
step,
cart,
shipping,
error,
validateAndProceed,
applyDiscountCode,
processPaymentAndComplete,
reset,
} = useCheckoutOrchestrator();
// Components don't need to know the complex flow!
// They just call the orchestrator's methods.
const handleAddressSubmit = async (addressData) => {
await validateAndProceed(addressData);
};
const handleDiscountApply = async (code) => {
await applyDiscountCode(code);
};
const handlePaymentSubmit = async (paymentDetails) => {
try {
await processPaymentAndComplete(paymentDetails);
// Navigation happens automatically in the orchestrator
} catch (err) {
// Error is already in state, but we can show a toast if needed
}
};
if (status === 'success') {
return <OrderConfirmation order={paymentResult} onNewOrder={reset} />;
}
return (
<div className="checkout">
{error && <ErrorAlert message={error} onDismiss={() => reset()} />}
{status === 'validating' || status === 'calculating' || status === 'paying' ? (
<LoadingSpinner message="Processing your order..." />
) : (
<>
{step === 1 && (
<AddressForm onSubmit={handleAddressSubmit} />
)}
{step === 2 && (
<>
<DiscountInput onApply={handleDiscountApply} />
<ShippingSelector
options={shipping}
onSelect={handleShippingSelect}
/>
<button onClick={() => setStep(3)}>Continue to Payment</button>
</>
)}
{step === 3 && (
<PaymentForm
total={calculateTotal(cart, shipping, discount)}
onSubmit={handlePaymentSubmit}
/>
)}
</>
)}
</div>
);
};
Benefits of the Orchestration Pattern
1. Separation of Concerns
- Components focus on presentation and user interactions
- Orchestrator handles the "how" and "when"
- API layers handle raw data fetching
2. Testability
Test the orchestrator in isolation without rendering UI:
test('checkout flow handles validation failure', async () => {
const { result } = renderHook(() => useCheckoutOrchestrator());
// Mock API to fail
jest.spyOn(api, 'validateAddress').mockRejectedValue(new Error('Invalid'));
await act(async () => {
await result.current.validateAndProceed({ street: '123 Main' });
});
expect(result.current.status).toBe('error');
expect(result.current.error).toBe('Invalid');
expect(result.current.step).toBe(1); // Still on address step
});
3. Reusability
The same orchestrator can be used across different UI implementations:
- Mobile checkout screen
- Desktop checkout modal
- Admin panel order creation
4. Observability
Centralized logic makes it easy to add logging, analytics, or error tracking:
const validateAndProceed = useCallback(async (address) => {
analytics.trackEvent('checkout_address_validation_started');
try {
// ... validation logic
analytics.trackEvent('checkout_address_validation_success');
} catch (error) {
analytics.trackEvent('checkout_address_validation_failed', { error });
Sentry.captureException(error);
}
}, []);
When to Use Orchestration (and When Not To)
✅ Great Use Cases:
- Multi-step forms (checkout, onboarding, surveys)
- Wizard-style workflows (report generation, deployment pipelines)
- Features with complex dependencies (dashboard with sequential data fetches)
- Operations requiring rollback/compensation (bank transfers, reservations)
❌ Overkill For:
- Simple CRUD forms with one API call
- Independent, isolated components with no coordination needs
- Small applications where complexity doesn't justify abstraction
Best Practices
Keep orchestrators stateless where possible — store state in React state or a state machine, not in the orchestrator instance itself.
Use TypeScript — define clear interfaces for your orchestrator's context and events:
interface CheckoutContext {
cart: Cart | null;
address: Address | null;
shipping: ShippingOption[] | null;
}
type CheckoutEvent =
| { type: 'VALIDATE_ADDRESS'; address: Address }
| { type: 'SELECT_SHIPPING'; method: ShippingMethod }
| { type: 'PROCESS_PAYMENT'; details: PaymentDetails };
Single responsibility — an orchestrator should coordinate ONE business process. Don't create a "god orchestrator" that handles checkout, profile updates, and notifications all in one place.
Compose orchestrators — for complex apps, create smaller orchestrators that work together:
function useOrderOrchestrator() {
const cart = useCartOrchestrator();
const checkout = useCheckoutOrchestrator();
const payment = usePaymentOrchestrator();
// Compose them into a higher-level workflow
}
Conclusion
The Orchestration Pattern transforms React applications from a collection of scattered useEffect hooks and imperative logic into a clean, declarative system. By centralizing workflow coordination, you get:
- Components that are simple and focused on presentation
- Orchestrators that clearly express business logic
- Code that's easier to test, debug, and maintain
Whether you implement it with custom hooks, XState, or a full workflow engine, the principle remains the same: coordinate complexity in one place, not everywhere.
Have you used the Orchestration Pattern in your React apps? What challenges did you face? Let me know in the comments! 👇
Top comments (0)