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:
// ❌ 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.
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.
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.
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.
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:
- User input state: What the user has typed. Persists across price updates.
- Live market data: Bid, ask, price from the feed. Updates independently.
- 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.
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.






Comments
Loading comments…