Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 072 / 100

Building a real-time order ticket: a step-by-step guide

What a trading order form taught me about complex React UIs.

Expona Admin9 min read

Building a real-time order ticket is one of those projects that looks straightforward until you're halfway through and your state is tangled, your validation logic is scattered, and price updates are firing at the wrong time. I learned this the hard way while rebuilding our trading platform's order form at a fintech startup. This article walks through the patterns that finally made it work.

Why order tickets are deceptively complex

An order ticket seems simple: enter a price, quantity, and hit submit. But real-time trading adds layers. Market orders execute at current price. Limit orders wait for a specific price. Each has different validation rules, different required fields, and different user workflows. Add live price feeds ticking every second, balance checks, lot-size constraints, and circuit breakers, and you've got a component that touches every part of your state management strategy.

I spent two weeks debugging a form where market price updates were overwriting user input, validation was running at the wrong time, and the submit button was enabled when it shouldn't be. The fix wasn't complicated, but it required separating concerns carefully.

Model order types as discriminated unions

The first mistake I made was treating market and limit orders as variations of a single state shape. I had optional fields scattered everywhere:

tsx
// ❌ Messy: optional fields everywhere
interface OrderForm {
  type: 'market' | 'limit';
  limitPrice?: number;  // only for limit
  timeInForce?: string; // only for limit
  quantity: number;
}

This led to bugs. I could submit a market order with a limit price accidentally set. The form didn't tell me which fields mattered. TypeScript wasn't helping.

The fix: discriminated unions. Each order type is its own shape.

tsx
type OrderForm = 
  | { type: 'market'; quantity: number; symbol: string }
  | { type: 'limit'; quantity: number; symbol: string; limitPrice: number; timeInForce: 'day' | 'gtc' };

// TypeScript now enforces shape per type
const createOrder = (form: OrderForm) => {
  switch (form.type) {
    case 'market':
      // form.limitPrice is inaccessible here ✓
      return submitMarketOrder(form.quantity, form.symbol);
    case 'limit':
      return submitLimitOrder(form.quantity, form.limitPrice, form.timeInForce);
  }
};

Now the form component knows exactly what fields to render and validate based on the selected type. No optional fields. No accidental state combinations.

tsx
const OrderTicket = () => {
  const [form, setForm] = useState({ type: 'market', quantity: 0, symbol: '' });

  return (
    <div>
      
        Order Type:
         {
          setForm(e.target.value === 'market' 
            ? { type: 'market', quantity: 0, symbol: '' }
            : { type: 'limit', quantity: 0, symbol: '', limitPrice: 0, timeInForce: 'day' }
          );
        }}>
          
          
        
      

       setForm({ ...form, quantity: parseFloat(e.target.value) })}
        placeholder="Quantity"
      />

      {form.type === 'limit' && (
         setForm({ ...form, limitPrice: parseFloat(e.target.value) })}
          placeholder="Limit Price"
        />
      )}
    </div>
  );
};

Live prices everywhere — separate the data streams

Real-time prices are the tricky part. When a new price ticks in, you need to update display fields instantly while preserving the user's input in quantity and price fields (for limit orders). I initially kept everything in one state object, which caused a nightmare.

The solution: separate user input from live market data.

tsx
const OrderTicket = () => {
  // User's edits
  const [form, setForm] = useState({ type: 'market', quantity: 100, symbol: 'AAPL' });

  // Live market data (updates from price feed)
  const [livePrice, setLivePrice] = useState(150.25);
  const [bid, setBid] = useState(150.23);
  const [ask, setAsk] = useState(150.27);

  // Derived totals (computed, never stored in state)
  const total = form.type === 'market' 
    ? form.quantity * ask 
    : form.type === 'limit' 
      ? form.quantity * form.limitPrice 
      : 0;

  // Price feed effect
  useEffect(() => {
    const unsubscribe = priceService.subscribe(form.symbol, (price) => {
      setLivePrice(price.mid);
      setBid(price.bid);
      setAsk(price.ask);
    });
    return unsubscribe;
  }, [form.symbol]);

  return (
    <div>
      <div>Current Market Price: ${livePrice.toFixed(2)}</div>
      <div>Bid: ${bid.toFixed(2)} | Ask: ${ask.toFixed(2)}</div>
      
       setForm({ ...form, quantity: parseFloat(e.target.value) })}
        placeholder="Quantity"
      />

      <div>Estimated Total: ${total.toFixed(2)}</div>
    </div>
  );
};

The key insight: the total is a derived value, not state. I compute it based on current form and market data. This ensures it's always in sync without race conditions.

Validation in real time — multiple concerns, one source of truth

Validation gets complex fast. For a $50k order, I need to check:

  • Does the user have sufficient balance?
  • Is the quantity a valid lot size (e.g., multiples of 100)?
  • Is the price within the exchange's circuit breaker bands?
  • For limit orders: is the price reasonable relative to the last trade?

I used to scatter this logic across the component. I'd validate on blur, on change, and before submit, each time reaching into different props and state. It was fragile.

Better approach: a validation hook that runs whenever dependencies change.

tsx
interface ValidationError {
  field: string;
  message: string;
}

const useOrderValidation = (
  form: OrderForm,
  livePrice: number,
  userBalance: number,
  priceCircuitBands: { min: number; max: number }
): ValidationError[] => {
  const errors: ValidationError[] = [];

  // Check balance
  const orderCost = form.type === 'market'
    ? form.quantity * livePrice * 1.01 // add slippage buffer
    : form.type === 'limit'
      ? form.quantity * form.limitPrice
      : 0;

  if (orderCost > userBalance) {
    errors.push({ field: 'quantity', message: `Insufficient balance. Need $${orderCost.toFixed(2)}, have $${userBalance.toFixed(2)}` });
  }

  // Check lot size
  if (form.quantity % 100 !== 0) {
    errors.push({ field: 'quantity', message: 'Quantity must be a multiple of 100' });
  }

  // Check price bands (limit orders only)
  if (form.type === 'limit') {
    if (form.limitPrice < priceCircuitBands.min || form.limitPrice > priceCircuitBands.max) {
      errors.push({ 
        field: 'limitPrice', 
        message: `Price must be between $${priceCircuitBands.min} and $${priceCircuitBands.max}` 
      });
    }
  }

  return errors;
};

const OrderTicket = ({ userBalance }: { userBalance: number }) => {
  const [form, setForm] = useState({ type: 'market', quantity: 100, symbol: 'AAPL' });
  const [livePrice, setLivePrice] = useState(150.25);
  const [priceCircuitBands] = useState({ min: 140, max: 160 });

  const errors = useOrderValidation(form, livePrice, userBalance, priceCircuitBands);
  const isValid = errors.length === 0;

  return (
    <div>
       setForm({ ...form, quantity: parseFloat(e.target.value) })}
      />
      {errors.find(e => e.field === 'quantity') && (
        <div>
          {errors.find(e => e.field === 'quantity')?.message}
        </div>
      )}

      {form.type === 'limit' && (
        <>
           setForm({ ...form, limitPrice: parseFloat(e.target.value) })}
          />
          {errors.find(e => e.field === 'limitPrice') && (
            <div>
              {errors.find(e => e.field === 'limitPrice')?.message}
            </div>
          )}
        
      )}

      
        Submit Order
      
    </div>
  );
};

The validation hook is pure. It takes immutable inputs and returns errors. I call it on every render (it's cheap). The component re-enables the button instantly as errors clear.

Separate state carefully — three layers

By the time I fixed the validation, I realized my mental model was clearer. There are three layers:

  1. User input state: What the user has typed. Persists across price updates.
  2. Live market data: Bid, ask, price from the feed. Updates independently.
  3. Derived values: Total cost, estimated execution price, fee. Never stored, always computed.

Mixing these layers is the root of most bugs. Market data shouldn't overwrite user input. Derived values shouldn't be stored in state. User input shouldn't block price updates.

Layer Updates from Persists? Example
User input onChange handlers Yes quantity: 100
Market data WebSocket subscription Yes bid: 150.23
Derived Computed from above No, recalculated each render total = quantity × ask

Enforcing this separation prevents so many issues. Use a custom hook to manage each layer, and compose them in your component.

tsx
const useOrderForm = () => {
  const [form, setForm] = useState({ type: 'market', quantity: 100, symbol: 'AAPL' });
  return { form, setForm };
};

const useMarketData = (symbol: string) => {
  const [bid, setBid] = useState(0);
  const [ask, setAsk] = useState(0);
  useEffect(() => {
    const unsub = priceService.subscribe(symbol, (price) => {
      setBid(price.bid);
      setAsk(price.ask);
    });
    return unsub;
  }, [symbol]);
  return { bid, ask };
};

const OrderTicket = () => {
  const { form, setForm } = useOrderForm();
  const { bid, ask } = useMarketData(form.symbol);
  const total = form.type === 'market' ? form.quantity * ask : form.quantity * form.limitPrice;

  return (
    <div>
      {/* Input fields update form */}
      {/* Market data displays bid/ask */}
      {/* Derived total re-renders when either changes */}
    </div>
  );
};

Common mistakes

  • Storing derived values in state: If you setTotal() in an effect, you've created a second source of truth. Always compute totals on render.
  • Mixing live prices with form state: Don't put market data in the same setState as user input. They have different lifetimes and update frequencies.
  • Validating only on submit: Users need feedback as they type. Run validation on every relevant input change.
  • Forgetting to unsubscribe from price feeds: Each price update triggers a re-render. If you don't clean up subscriptions, you'll leak listeners and cause memory leaks.
  • Not handling edge cases in discriminated unions: A switch statement without a default case will silently fail if a new type is added. Use exhaustiveness checking.

Key takeaways

  • Model different order types as discriminated unions so TypeScript enforces the correct shape for each variant.
  • Keep user input state, live market data, and derived values in separate state objects to avoid race conditions and accidental overwrites.
  • Run validation as a pure hook on every render, depending on the data that affects it.
  • Compute derived values (like totals) on every render instead of storing them; they're always fresh and you avoid sync bugs.
  • Use custom hooks to encapsulate each concern (form state, market data, validation) and compose them in your component.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles