Skip to main content

React Inventory Management: Handle Stock & Availability

Inventory management is the bridge between your React frontend and your backend database. Every time a customer adds an item to their cart or completes a purchase, inventory must be updated in real time to prevent overselling—the cardinal sin of ecommerce, where you promise a product you don't have. Building robust inventory management in React requires syncing with your backend API, handling concurrent updates, preventing race conditions, and gracefully handling stock-out scenarios. This article shows you how production stores handle millions of concurrent transactions, manage inventory across warehouses, and recover from sync failures.

Understand Inventory State Models​

Inventory exists in multiple states:

Stock: Total units on hand.

Reserved: Units in active shopping carts (not yet paid).

Available: Stock minus reserved (what customers can add to cart).

Pending: Units in ongoing shipments from suppliers.

Damaged/Defective: Units that cannot be sold.

Your frontend needs to know available, while the backend tracks all states:

CREATE TABLE inventory (
id SERIAL PRIMARY KEY,
product_id UUID NOT NULL UNIQUE,
stock INT DEFAULT 0,
reserved INT DEFAULT 0,
available INT GENERATED ALWAYS AS (stock - reserved) STORED,
pending INT DEFAULT 0,
damaged INT DEFAULT 0,
warehouse_id UUID,
last_updated TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (product_id) REFERENCES products(id),
FOREIGN KEY (warehouse_id) REFERENCES warehouses(id)
);

Sync Inventory on Page Load​

When your React app mounts, fetch fresh inventory data:

// src/hooks/useInventorySync.js
import { useEffect } from 'react';
import { useInventoryStore } from '../store/inventoryStore';

export function useInventorySync() {
const syncInventory = useInventoryStore((state) => state.syncInventory);

useEffect(() => {
// Fetch inventory on mount
syncInventory();

// Periodically refresh (every 1 minute for real-time accuracy)
const interval = setInterval(() => {
syncInventory();
}, 60000);

return () => clearInterval(interval);
}, [syncInventory]);
}

Your Zustand store handles the sync:

export const useInventoryStore = create((set) => ({
inventory: {},
lastSync: null,
syncing: false,

syncInventory: async () => {
set({ syncing: true });

try {
const response = await fetch(
`${import.meta.env.VITE_API_URL}/inventory`
);
const data = await response.json();

set({
inventory: data.reduce((acc, item) => {
acc[item.productId] = {
stock: item.stock,
reserved: item.reserved,
available: item.available,
lastUpdated: item.lastUpdated,
};
return acc;
}, {}),
lastSync: new Date(),
syncing: false,
});
} catch (error) {
console.error('Failed to sync inventory:', error);
set({ syncing: false });
}
},
}));

Prevent Overselling with Reservations​

When a customer adds an item to their cart, immediately reserve it. This prevents another customer from buying the last unit while the first customer is checking out.

export const useCartStore = create((set, get) => ({
items: [],

addItem: async (product, quantity = 1) => {
const inventoryStore = useInventoryStore.getState();

// Check availability
if (!inventoryStore.isAvailable(product.id, quantity)) {
throw new Error('Item is out of stock');
}

// Optimistically add to cart
set((state) => ({
items: [...state.items, { ...product, quantity }],
}));

// Reserve on backend
try {
const response = await fetch(
`${import.meta.env.VITE_API_URL}/cart/reserve`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: product.id, quantity }),
}
);

if (!response.ok) {
// Revert if reservation failed
set((state) => ({
items: state.items.filter((item) => item.id !== product.id),
}));
throw new Error('Failed to reserve item');
}
} catch (error) {
console.error('Reservation failed:', error);
throw error;
}
},

removeItem: async (productId) => {
const item = get().items.find((i) => i.id === productId);
if (!item) return;

// Release reservation on backend
try {
await fetch(`${import.meta.env.VITE_API_URL}/cart/release`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, quantity: item.quantity }),
});

// Remove from cart
set((state) => ({
items: state.items.filter((i) => i.id !== productId),
}));
} catch (error) {
console.error('Failed to release reservation:', error);
}
},
}));

On your backend, manage reservations:

// Reserve inventory
app.post('/api/cart/reserve', async (req, res) => {
const { productId, quantity } = req.body;
const userId = req.user.id; // From auth middleware

try {
// Check availability
const inv = await db.query(
'SELECT available FROM inventory WHERE product_id = $1',
[productId]
);
if (inv.rows[0].available < quantity) {
return res.status(400).json({ error: 'Out of stock' });
}

// Create reservation record
await db.query(
'INSERT INTO reservations (user_id, product_id, quantity, expires_at) VALUES ($1, $2, $3, NOW() + INTERVAL 30 MINUTES)',
[userId, productId, quantity]
);

// Update inventory
await db.query(
'UPDATE inventory SET reserved = reserved + $1 WHERE product_id = $2',
[quantity, productId]
);

res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});

// Release reservation (on cart remove)
app.post('/api/cart/release', async (req, res) => {
const { productId, quantity } = req.body;
const userId = req.user.id;

try {
await db.query(
'DELETE FROM reservations WHERE user_id = $1 AND product_id = $2',
[userId, productId]
);

await db.query(
'UPDATE inventory SET reserved = GREATEST(0, reserved - $1) WHERE product_id = $2',
[quantity, productId]
);

res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});

Handle Reservation Expiration​

Reservations expire after 30 minutes (configurable). A cron job cleans up expired reservations:

// Clean up expired reservations every 5 minutes
setInterval(async () => {
try {
const result = await db.query(
'DELETE FROM reservations WHERE expires_at < NOW() RETURNING product_id, quantity'
);

// Release inventory for expired reservations
for (const row of result.rows) {
await db.query(
'UPDATE inventory SET reserved = GREATEST(0, reserved - $1) WHERE product_id = $2',
[row.quantity, row.product_id]
);
}

console.log(`Cleaned up ${result.rowCount} expired reservations`);
} catch (error) {
console.error('Failed to clean expired reservations:', error);
}
}, 5 * 60 * 1000);

Deduct Inventory on Successful Payment​

Only deduct stock after payment is confirmed (from Stripe webhook):

// Handle Stripe webhook: payment_intent.succeeded
app.post('/webhooks/stripe', async (req, res) => {
const event = req.body;

if (event.type === 'payment_intent.succeeded') {
const { orderId } = event.data.object.metadata;

try {
// Get order items
const order = await db.query(
'SELECT items FROM orders WHERE id = $1',
[orderId]
);

const items = order.rows[0].items;

// Deduct from inventory
for (const item of items) {
await db.query(
'UPDATE inventory SET stock = stock - $1 WHERE product_id = $2',
[item.quantity, item.productId]
);
}

// Clear reservations
await db.query(
'DELETE FROM reservations WHERE product_id = ANY($1)',
[items.map((i) => i.productId)]
);

// Mark order as paid
await db.query(
'UPDATE orders SET status = $1 WHERE id = $2',
['paid', orderId]
);
} catch (error) {
console.error('Failed to deduct inventory:', error);
}
}

res.json({ received: true });
});

Show Stock Status on Product Pages​

Display inventory status to help customers make decisions:

// src/components/StockStatus.jsx
import { useInventoryStore } from '../store/inventoryStore';

export default function StockStatus({ productId }) {
const inventory = useInventoryStore((state) => state.inventory[productId]);

if (!inventory) return null;

const { available } = inventory;

if (available <= 0) {
return (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
Out of Stock
</div>
);
}

if (available <= 5) {
return (
<div className="bg-orange-100 border border-orange-400 text-orange-700 px-4 py-3 rounded">
Only {available} left in stock!
</div>
);
}

return (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">
In Stock ({available} available)
</div>
);
}

Comparison: Inventory Architectures​

ApproachAccuracyComplexityScale
Frontend OnlyLow (no sync)Low<1,000 items
Periodic Backend SyncMedium (1-min lag)Medium10,000 items
Real-Time ReservationsHigh (second-level)High100,000+ items
Event-Driven (Kafka)Very High (sub-second)Very High1M+ items, multi-warehouse

For most ecommerce stores, real-time reservations (article approach) is the sweet spot.

Key Takeaways​

  • Inventory has multiple states (stock, reserved, available); track all of them.
  • Reserve items when added to cart; release when removed or checkout cancelled.
  • Deduct stock only after payment confirmation, not before.
  • Expire reservations after 30 minutes to free up inventory for other customers.
  • Sync inventory from backend every 1–5 minutes so frontend knows availability.

Frequently Asked Questions​

What if two customers try to buy the last item simultaneously?​

Database-level constraints prevent overselling. Your UPDATE inventory SET stock = stock - $1 is atomic; only one succeeds, the other fails and triggers a "out of stock" error for the second customer.

Can I track inventory across multiple warehouses?​

Yes. Add a warehouse_id column to your inventory table. When a customer checks out, choose a warehouse based on proximity or stock availability, then deduct from that warehouse.

How do I handle inventory corrections (physical count)?​

Add an admin endpoint to manually adjust stock:

app.post('/api/admin/inventory/adjust', adminAuth, async (req, res) => {
const { productId, adjustment, reason } = req.body;
await db.query(
'UPDATE inventory SET stock = stock + $1 WHERE product_id = $2',
[adjustment, productId]
);
// Log the adjustment for audit
});

What if my inventory service goes down?​

Fail gracefully: show products as unavailable until sync restores. Implement circuit breakers so failed syncs don't cascade into app crashes.

Further Reading​