Critique from the Trenches:
“But isn’t this declarative approach going to introduce tons of side effects? If we stick to imperative code, we can just flow from top to bottom and debug where it goes wrong.” This is a valid concern—imperative code often feels more direct, more “honest” about what’s happening step-by-step. If debugging were always trivial and the world always full of candy and rainbows, maybe we’d never need another approach.
But as complexity grows, that simple top-to-bottom logic turns into sprawling forests of conditions, repetitions, and subtle bugs hiding in corners. Let’s illustrate this by examining a real-world surgical operation we performed on a legacy React-based ERP dashboard. Initially, it was built in an imperative style. Over time, we shifted it toward a declarative paradigm. We’ll show what that looked like, why we bothered, and how it actually makes debugging more predictable once you acclimate to the new structure.
The Original Legacy ERP Dashboard (Imperative Nightmare)
Context: We had a React ERP dashboard that pulled data from dozens of endpoints, displayed tables, charts, and forms. With each new feature or endpoint, we added more useEffect
hooks, more axios.get()
calls, and more conditionals in the render methods.
A Typical Component Before:
function OrdersPage() {
const [orders, setOrders] = useState(null);
const [filteredOrders, setFilteredOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
axios.get('/api/orders')
.then(res => {
setOrders(res.data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
useEffect(() => {
if (orders) {
// Imperative filtering logic
const result = orders.filter(o => o.status === 'OPEN');
setFilteredOrders(result);
}
}, [orders]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (filteredOrders.length === 0) return <p>No open orders</p>;
return (
<table>
<thead><tr><th>ID</th><th>Status</th></tr></thead>
<tbody>
{filteredOrders.map(o => (
<tr key={o.id}>
<td>{o.id}</td>
<td>{o.status}</td>
</tr>
))}
</tbody>
</table>
);
}
What’s Wrong Here?
- We’re mixing data fetching, filtering, and rendering concerns in one place.
- Debugging a bug in filtering means tracing through effects, checking whether
orders
arrived on time, and understanding the interplay between states. - Adding a new requirement—like fetching related customer data—sprawls more hooks, conditions, and potential side effects.
Yes, you can see the code top-to-bottom, but as it grows, “top to bottom” becomes a maze of cross-references and scattered logic. Debugging is not always easier in a large imperative codebase; it can quickly become a whack-a-mole game of console.logs and guesswork.
Shifting to a Declarative Paradigm
Instead of dealing directly with axios
calls and ad-hoc filters in components, we introduced a declarative model layer. We defined models like Order
and gave them adapters responsible for fetching data, caching, and filtering at a higher level.
Declarative Model Setup:
class Order {
static schema = { id: 'number', status: 'string' };
static adapter = OrdersAdapter; // Adapter knows how to fetch, maybe even filter by status
}
class OrdersAdapter extends APIAdapter {
static baseURL = '/api/';
static endpoint = 'orders';
// Overriding `query` method to handle filtering logic inside the adapter:
async query(params = {}) {
const data = await super.query(params);
// Suppose we handle filtering at the adapter level
return data.filter(o => o.status === params.status);
}
}
In the Component (After):
function OrdersPage() {
const [orders, setOrders] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
(async () => {
try {
// Declarative: We ask for orders with a certain condition
const openOrders = await Order.objects.query({ status: 'OPEN' });
setOrders(openOrders);
} catch (err) {
setError(err);
}
})();
}, []);
if (!orders && !error) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (orders.length === 0) return <p>No open orders</p>;
return (
<table>
<thead><tr><th>ID</th><th>Status</th></tr></thead>
<tbody>
{orders.map(o => (
<tr key={o.id}>
<td>{o.id}</td>
<td>{o.status}</td>
</tr>
))}
</tbody>
</table>
);
}
What Changed?
- We no longer manually fetch, filter, and track multiple states separately. We request what we want (
query({ status: 'OPEN' })
) and trust the declarative layer to deliver. - Debugging a filtering issue now means checking the adapter logic, not rummaging through component effects. We know where the filtering is declared. The complexity is isolated.
Addressing the Side-Effect Concern
The fear is that declarative code “hides” side effects. But consider this:
-
In the Imperative World: Side effects are everywhere. Every
useEffect
might trigger another, and logic can become tangled. Debugging top-to-bottom is harder when each piece of code modifies states that ripple through other hooks. -
In the Declarative World: You centralize and compartmentalize side effects. The adapter or model layer is where the “magic” happens. Instead of searching multiple components for where filtering occurs, you know it’s in
Order.objects.query()
. The complexity is at a known point, making it more predictable where to investigate.
Hypothetical Debugging Scenario:
-
Imperative Approach: A bug in filtered orders could originate from API response parsing, a conditional in
useEffect
, or a race condition between two hooks. -
Declarative Approach: A bug in filtered orders likely lives in
OrdersAdapter
or inOrder
’s schema. Debugging means you go to one place—the adapter logic—rather than checking every component that uses orders.
A Surgical Operation on a Legacy ERP Dashboard
We took a legacy ERP dashboard that had grown too complex. Each new feature added more complexity in the React components, making it harder to reason about state and side effects. We surgically moved logic into models and adapters:
- Centralizing Data Logic: Instead of multiple components doing their own fetching and processing, we defined adapters that handled these responsibilities centrally.
- Declaring Intent, Not Instructions: Components now say “I want open orders” rather than orchestrating the steps to fetch and filter them.
- Reduced Cross-Component Side Effects: By removing imperative data flows from components, you reduce the chance that changing one component’s data logic breaks another’s.
Over time, this made the system more stable. Yes, it required an investment: writing adapters, defining schemas, and training the team to understand the new patterns. But once in place, debugging became more about “Check the adapter logic” rather than “Sprinkle console.logs in every hook.”
Conclusion: Not Candy and Rainbows, But Consistency and Clarity
Switching to a declarative paradigm doesn’t mean your code is now perfect or that debugging is always easy. It does mean you have a more intentional architecture. Side effects don’t vanish; they’re just more predictable and centralized. Instead of random spots in dozens of components, you have defined places to inspect.
It’s not that life is all candy and rainbows now—it’s that you’ve moved from herding cats to dealing with well-organized kennels. You still manage animals, but they’re fenced in and labeled. For senior devs who’ve struggled with complex legacy code, this shift can be a relief. You trade a messy imperative spaghetti for a structured, declarative lasagna. Both might have layers, but one is definitely easier to slice through.
Top comments (0)