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