As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
If you've ever built a JavaScript application that grew beyond a simple to-do list, you know the feeling. The state—the data that represents what's happening in your app—starts to get messy. A user's profile here, a shopping cart there, UI toggle flags everywhere. It becomes a web of dependencies where changing one thing breaks three others. I've been there, refactoring late into the night, promising myself I'd find a better way next time.
That next time is about moving beyond the basics. While tools like React's useState are fantastic starting points, complex applications demand more systematic approaches. We need patterns that make our data flows predictable, our logic reusable, and our apps able to handle many users and real-time updates without becoming a tangled mess.
Let's talk about some of these advanced patterns. I'll show you the concepts and give you concrete code you can use or adapt. Think of this as building a toolkit. Not every tool is for every job, but knowing what's in the box helps you fix problems before they happen.
Imagine your app's login process. A user can be idle, loading, logged in, logged out, or in an error state. You might track this with a simple string: let authState = 'idle'. But what happens if you try to log out while in the 'loading' state? Or retry a failed login more than three times? These rules get sprinkled in if statements across your code.
A more structured way is to think of your application as a machine with defined states and rules. This is a State Machine. You list every possible state your app can be in. Then, you define the only possible ways to move from one state to another. This makes illegal states impossible to represent in your code.
Here's a practical look. Instead of a string, we model our authentication as a graph. From 'idle', you can only move to 'loading' if a LOGIN event happens. From 'loading', you can only move to 'success' or 'error'. This logic is centralized, not scattered.
I built a simple class to encapsulate this idea. You give it a configuration object that maps out all your states and the events that trigger changes between them.
// A compact example of a state machine for a light switch
const lightSwitchMachine = {
initial: 'off',
states: {
off: {
on: { TOGGLE: 'on' }
},
on: {
on: { TOGGLE: 'off' }
}
}
};
const switch = new StateMachine(lightSwitchMachine);
switch.transition('TOGGLE'); // State is now 'on'
switch.transition('TOGGLE'); // State is now 'off'
switch.transition('TOGGLE'); // State is now 'on'
For something more real, like authentication, the configuration gets more detailed but also more clear. You can see the entire flow in one place.
const authConfig = {
initial: 'idle',
context: { user: null, error: null },
states: {
idle: { on: { LOGIN: 'loading' } },
loading: {
on: {
SUCCESS: { target: 'authenticated', actions: 'setUser' },
FAILURE: { target: 'error', actions: 'setError' }
}
},
authenticated: { on: { LOGOUT: 'idle' } },
error: { on: { RETRY: 'loading' } }
},
actions: {
setUser: (context, event) => { context.user = event.user; },
setError: (context, event) => { context.error = event.message; }
}
};
This approach forces you to think through every scenario. What happens on a network timeout? That's a FAILURE event. What should the user see? The error state. It turns implicit, bug-prone logic into explicit, verifiable rules. You can even visualize this as a diagram, which is a great way to communicate with your team.
When your state becomes a large, nested object—like a user profile with preferences, a complex form, or a document—managing updates efficiently is key. You don't want every tiny change to cause every part of your UI to re-render.
This is where the idea of observable, fine-grained reactivity comes in. The goal is to track dependencies automatically, so only the parts of your app that care about a specific piece of data update when that data changes.
One powerful way to do this is through atomic state. You break your application state down into the smallest independent pieces, called "atoms." An atom could be a username, a theme preference, or an item in a cart. Then, you build "selectors," which are pieces of state derived from other atoms. A cart's total price is a selector; it depends on the cart items atom and maybe a discount atom.
I created a simple AtomicStore to demonstrate this pattern. Atoms are registered with a unique key. You can get their value, set their value, and subscribe to changes.
const store = new AtomicStore();
// Create some fundamental atoms
const usernameAtom = store.atom('username', '');
const isDarkModeAtom = store.atom('isDarkMode', false);
// Subscribe to changes
const unsubscribe = store.subscribe('username', (newName) => {
console.log(`Username changed to: ${newName}`);
});
// Update an atom
store.set('username', 'Alice'); // The subscriber logs: "Username changed to: Alice"
// Don't forget to unsubscribe when done to prevent memory leaks
unsubscribe();
The real magic is in selectors. A selector automatically recalculates only when one of its dependencies changes. This is efficient and declarative.
// Assume we have a cart atom
const cartAtom = store.atom('cart', [
{ name: 'Book', price: 20, quantity: 1 },
{ name: 'Pen', price: 2, quantity: 3 }
]);
const taxRateAtom = store.atom('taxRate', 0.08); // 8%
// A selector for the subtotal (depends only on cart)
const subtotalSelector = store.selector('subtotal', (store) => {
const cart = store.get('cart');
return cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
}, ['cart']); // List of dependency keys
// A selector for the total with tax (depends on subtotal AND taxRate)
const totalWithTaxSelector = store.selector('totalWithTax', (store) => {
const subtotal = store.get('subtotal');
const taxRate = store.get('taxRate');
return subtotal * (1 + taxRate);
}, ['subtotal', 'taxRate']);
// Now, if we update the cart...
store.set('cart', [...store.get('cart'), { name: 'Notebook', price: 5, quantity: 1 }]);
// The 'subtotal' selector recalculates.
// Because 'subtotal' changed, the 'totalWithTax' selector also recalculates automatically.
console.log(store.get('totalWithTax')); // Always up-to-date
This pattern is incredibly scalable. Different UI components can subscribe to just the atoms or selectors they need. Updating a username doesn't cause the cart total component to re-render. It also makes testing trivial, as you can test each atom and selector in isolation.
Sometimes, you need more control over how state changes happen, especially when you want features like undo/redo, audit logging, or queuing actions. This is where the Command Pattern shines.
Instead of directly setting state, you create command objects. Each command knows how to perform an action (like "Add Item to Cart") and, crucially, how to reverse it. A central command manager executes these commands, keeps a history stack, and can undo or redo them.
Let's build a simple command system. First, we define a base Command class.
class Command {
constructor(description) {
this.description = description;
}
execute(state) {
// Applies the change, returns the new state
throw new Error('Must implement execute()');
}
undo(state) {
// Reverts the change, returns the previous state
throw new Error('Must implement undo()');
}
}
Now, let's make a concrete command for adding an item to a shopping cart.
class AddItemCommand extends Command {
constructor(item) {
super(`Add ${item.name} to cart`);
this.item = item;
this.previousCartState = null; // We'll store the old state here for undo
}
execute(state) {
// Save the current cart for the undo operation
this.previousCartState = [...state.cart];
// Perform the action: add the item
const newCart = [...state.cart, this.item];
// Return the new full application state
return { ...state, cart: newCart };
}
undo(state) {
// Simply revert to the previous cart state we saved
return { ...state, cart: this.previousCartState };
}
}
The power comes from the manager that orchestrates these commands.
class CommandManager {
constructor(initialState) {
this.state = initialState;
this.history = []; // Stack of executed commands
this.redoStack = []; // Stack of undone commands
}
execute(command) {
// Execute the command and get new state
this.state = command.execute(this.state);
// Push it onto the history
this.history.push(command);
// Clear the redo stack (a new branch in history)
this.redoStack = [];
console.log(`Executed: ${command.description}`);
}
undo() {
if (this.history.length === 0) return;
// Pop the last command
const command = this.history.pop();
// Undo it
this.state = command.undo(this.state);
// Push it onto the redo stack
this.redoStack.push(command);
console.log(`Undid: ${command.description}`);
}
redo() {
if (this.redoStack.length === 0) return;
// Pop from redo stack
const command = this.redoStack.pop();
// Re-execute it
this.state = command.execute(this.state);
// Put it back in history
this.history.push(command);
console.log(`Redid: ${command.description}`);
}
getState() {
return this.state;
}
}
Now, see it in action with a simple shopping cart.
// Our app's starting state
const initialState = { cart: [], user: 'Guest' };
const manager = new CommandManager(initialState);
// Create and execute commands
const addBookCmd = new AddItemCommand({ name: 'JavaScript Guide', price: 30 });
manager.execute(addBookCmd);
console.log(manager.getState().cart); // Cart has one book
const addCoffeeCmd = new AddItemCommand({ name: 'Coffee', price: 5 });
manager.execute(addCoffeeCmd);
console.log(manager.getState().cart); // Cart has book and coffee
// Oops, let's undo that last addition
manager.undo();
console.log(manager.getState().cart); // Back to just the book
// Changed my mind, redo it
manager.redo();
console.log(manager.getState().cart); // Book and coffee again
This pattern is fantastic for complex business logic. It turns state changes into first-class objects you can log, serialize, queue, or even send over a network for collaborative features. It cleanly separates the "what" (the command) from the "how" (the state update logic).
In a real application, you often need to run extra logic around state changes. You might want to log every action for debugging, validate data before it's committed, or automatically save state to localStorage. Adding this logic directly into your state-update functions makes them messy.
A middleware pipeline offers a cleaner solution. It's like a series of checkpoints a state change must pass through. Each middleware can inspect, modify, reject, or log the change.
Let's enhance our CommandManager to support middleware. The idea is simple: before a command executes, it goes through each middleware's beforeExecute hook. After it executes, it goes through the afterExecute hook.
class CommandManagerWithMiddleware extends CommandManager {
constructor(initialState) {
super(initialState);
this.middleware = [];
}
use(middlewareFn) {
this.middleware.push(middlewareFn);
}
execute(command) {
let currentState = this.state;
// 1. Run all "before" hooks
for (const mw of this.middleware) {
if (mw.beforeExecute) {
const result = mw.beforeExecute(currentState, command);
if (result === false) {
console.log(`Command blocked by middleware: ${command.description}`);
return false; // Stop execution
}
}
}
// 2. Execute the command
const newState = command.execute(currentState);
this.state = newState;
this.history.push(command);
this.redoStack = [];
// 3. Run all "after" hooks
for (const mw of this.middleware) {
if (mw.afterExecute) {
mw.afterExecute(newState, command);
}
}
return true;
}
}
Now, let's create some useful middleware.
// 1. Logging Middleware
const loggerMiddleware = {
beforeExecute(state, command) {
console.log(`[${new Date().toISOString()}] About to execute: ${command.description}`);
console.log('State before:', state);
},
afterExecute(state, command) {
console.log(`[${new Date().toISOString()}] Finished: ${command.description}`);
console.log('State after:', state);
}
};
// 2. Validation Middleware (for our AddItemCommand)
const validationMiddleware = {
beforeExecute(state, command) {
if (command instanceof AddItemCommand) {
// Simple validation: item must have a name and positive price
if (!command.item.name || command.item.price <= 0) {
console.error('Invalid item: must have name and positive price.');
return false; // Block the command
}
}
return true; // Allow the command
}
};
// 3. Persistence Middleware (auto-save to localStorage)
const persistenceMiddleware = {
afterExecute(state, command) {
try {
localStorage.setItem('myAppState', JSON.stringify(state));
console.log('State saved to localStorage.');
} catch (e) {
console.error('Failed to save state:', e);
}
}
};
Using them together is straightforward.
const manager = new CommandManagerWithMiddleware({ cart: [] });
// Register middleware
manager.use(loggerMiddleware);
manager.use(validationMiddleware);
manager.use(persistenceMiddleware);
// Try to execute a valid command
const validCmd = new AddItemCommand({ name: 'Valid Item', price: 10 });
manager.execute(validCmd); // Logs, validates, executes, saves.
// Try to execute an invalid command
const invalidCmd = new AddItemCommand({ name: '', price: -5 });
manager.execute(invalidCmd); // Logs, validation fails, command is blocked.
// Console: "Invalid item: must have name and positive price."
This architecture keeps your core command logic clean and lets you plug in cross-cutting concerns modularly. Need analytics? Write an analytics middleware. Need to sync with a server? Write a sync middleware.
Modern applications are often collaborative. Multiple people might edit a document simultaneously, or you might have data coming from a server while a user makes local changes. This introduces the challenge of conflict: what happens when two changes happen to the same data at the same time?
Optimistic UI is a common pattern to handle this smoothly. The idea is to update the user interface immediately with the user's change, assuming it will succeed. In the background, you send the change to the server. If the server confirms it, great. If the server rejects it or sends a conflicting update, you roll back the local change and show the correct state.
Here's a conceptual flow for an optimistic update to a "like" counter:
let post = { id: 1, likes: 10, title: 'My Post' };
function optimisticLike() {
// 1. Save the current state for potential rollback
const previousLikes = post.likes;
// 2. Update the UI optimistically
post.likes = previousLikes + 1;
updateUI(post);
// 3. Send the request to the server
serverApi.likePost(post.id).then(
(serverResponse) => {
// 4a. Success! Server agrees. Maybe sync the exact count.
post.likes = serverResponse.newLikeCount;
updateUI(post);
},
(error) => {
// 4b. Failure! Roll back the optimistic update.
post.likes = previousLikes;
updateUI(post);
alert('Could not like the post. Please try again.');
}
);
}
For more complex collaborative editing, like in Google Docs, techniques like Operational Transformation (OT) or Conflict-Free Replicated Data Types (CRDTs) are used. These are advanced algorithms that ensure all users eventually see the same document, even if they typed at the same time.
While implementing a full OT system is complex, the core idea is that instead of sending the final state ("the document is now 'ABC'"), you send the operation ("insert 'B' at position 2"). These operations can be transformed against each other to resolve conflicts. If you and I both insert a character at the same position, the algorithm defines a consistent rule for which comes first.
Finally, as your application state grows, performance can become a concern. A technique called "state batching" or "debounced updates" is vital. If a user rapidly fires many events (like typing in a search box that filters a list), you don't want to recalculate and re-render on every keystroke.
The atomic store we built earlier has a simple form of this with transactions. But a more common approach is to use a debounced function to limit the rate of updates.
function createBatchedUpdater(updateFn, delay = 50) {
let timeoutId = null;
let pendingUpdates = [];
return function batchedUpdate(update) {
pendingUpdates.push(update);
if (timeoutId === null) {
// Schedule the batched update after the delay
timeoutId = setTimeout(() => {
// Apply all pending updates at once
const finalState = pendingUpdates.reduce((state, updater) => updater(state), getCurrentState());
updateFn(finalState);
// Reset
pendingUpdates = [];
timeoutId = null;
}, delay);
}
};
}
// Usage with a hypothetical store
const store = { filterText: '', items: [...] };
const batchedSetFilter = createBatchedUpdater((newState) => {
console.log('Updating UI with state:', newState);
// This would trigger your UI render
});
// Simulate rapid user input
batchedSetFilter(state => ({ ...state, filterText: 'h' }));
batchedSetFilter(state => ({ ...state, filterText: 'he' }));
batchedSetFilter(state => ({ ...state, filterText: 'hel' }));
batchedSetFilter(state => ({ ...state, filterText: 'hell' }));
batchedSetFilter(state => ({ ...state, filterText: 'hello' }));
// Only one "Updating UI with state:" log will appear ~50ms after the last call.
This significantly reduces the work your app does, leading to a smoother user experience.
Choosing the right pattern depends on your problem. For a well-defined UI flow (like a checkout process), a state machine is excellent. For a large, reactive data model (like a dashboard with many interconnected metrics), an atomic/selector pattern works wonders. When you need undo history or strict auditing, the command pattern is your friend. For real-time collaboration, you'll look into OT or CRDTs.
The key takeaway is that state management is an architecture problem, not just a coding problem. By investing in these patterns early, you build a foundation that can scale with your application's complexity. Your code becomes more predictable, easier to debug, and more enjoyable to maintain. You spend less time chasing weird bugs and more time building features that matter to your users. And that, in my experience, is always worth the effort.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)