Course: JavaScript Intermediate
Lesson 20: Intermediate Recap: Data-Driven UI Logic
Level
Intermediate
Estimated Reading Time
10–15 minutes
Goal
By the end of this lesson, the learner will be able to:
- Combine fetch, state management, and DOM rendering in a single mini-app
- Recognize how intermediate JavaScript concepts work together in a real feature
- Identify which concept applies to each part of an app's logic
Frontend Development Context
The intermediate JavaScript concepts you have learned are not isolated tools — they work together. async/await fetches data. State management tracks the app's current condition. Array methods transform the data. Destructuring cleans up property access. The DOM API renders results. Error handling keeps the app stable when something goes wrong. Event delegation handles user interactions efficiently.
This final lesson ties these pieces together into one realistic mini-app: a searchable post browser that fetches data from an API, stores it in state, filters it based on user input, and renders the results. Reading this code as a whole helps you see how intermediate JavaScript actually looks in practice — not as separate exercises, but as coordinated parts of one feature.
Explanation
A data-driven UI separates three concerns: fetching data, managing state, and rendering. When these three responsibilities are clearly organized, the code is easier to extend, debug, and hand to another developer.
The fetch layer communicates with external services and returns raw data. It handles HTTP errors and network failures, and it provides the rest of the app with clean, usable data or a clear error signal.
The state layer stores the data the app is currently working with — the full dataset, any active filters, loading status, and error messages. It is the single source of truth. Every user interaction changes state, which triggers a re-render.
The render layer reads state and builds DOM elements to match. It does not care where the data came from or how it changed — it just shows the current state accurately. Functions in the render layer use map, destructuring, and template literals to build elements cleanly and consistently.
Keeping these layers separate, even informally in a vanilla JavaScript app, makes the code dramatically easier to maintain. When a bug appears, you know immediately which layer to look at: wrong data means look at fetch, wrong display means look at render, wrong behavior means look at state.
Code Example
This mini-app fetches posts from an API, stores them in state, filters by search query, and renders the results with error and loading handling.
// --- State ---
const state = {
posts: [],
query: "",
loading: false,
error: null
};
// --- Fetch Layer ---
async function loadPosts() {
state.loading = true;
state.error = null;
render();
try {
const response = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=20");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const posts = await response.json();
state.posts = posts;
} catch (err) {
state.error = "Failed to load posts. Please try again.";
} finally {
state.loading = false;
render();
}
}
// --- State Update ---
function setQuery(query) {
state.query = query.toLowerCase();
render();
}
// --- Derived Data ---
function getFilteredPosts() {
if (!state.query) return state.posts;
return state.posts.filter(({ title, body }) =>
title.toLowerCase().includes(state.query) ||
body.toLowerCase().includes(state.query)
);
}
// --- Render Layer ---
function render() {
const container = document.querySelector("#posts");
const status = document.querySelector("#status");
if (state.loading) {
status.textContent = "Loading...";
container.innerHTML = "";
return;
}
if (state.error) {
status.textContent = state.error;
container.innerHTML = "";
return;
}
const visible = getFilteredPosts();
status.textContent = `${visible.length} post(s) found`;
container.innerHTML = "";
visible.forEach(({ id, title, body }) => {
const card = document.createElement("article");
card.innerHTML = `
<h3>#${id}: ${title}</h3>
<p>${body.substring(0, 100)}…</p>
`;
container.appendChild(card);
});
}
// --- Event Handling ---
document.querySelector("#searchInput").addEventListener("input", function (event) {
setQuery(event.target.value);
});
// --- Init ---
loadPosts();
Code Explanation
state is the single object holding all data the UI depends on: the posts array, the search query, the loading flag, and any error message. Each state property has a clear purpose, and the render function uses all of them.
loadPosts is an async function that sets state.loading = true, calls render() to show the loading indicator, then awaits the fetch. The try/catch/finally pattern ensures loading is always set back to false and render is called regardless of success or failure.
setQuery is the state update function for search input. It normalizes the query to lowercase and calls render(). The event listener calls setQuery on every input event, so the list filters in real time as the user types.
getFilteredPosts is a derived computation: it applies the current query to the posts array. It uses array destructuring ({ title, body }) in the filter callback to cleanly access only the properties it needs.
render checks state flags in order: loading first, then error, then success. Each branch returns early if its condition is met. The success branch calls getFilteredPosts(), clears the container, and builds a card for each post using destructuring and template literals.
Pattern Highlights
✅ Positive Pattern: Organize your app into clearly separated layers — fetch, state, and render. Even without a framework, this structure makes the code readable, debuggable, and easy to extend with new features.
⚠️ Neutral Note: Calling
render()from withinloadPosts(once at the start for loading state, once at the end viafinally) is a practical pattern. In larger apps, a reactive system triggers re-renders automatically when state changes, removing the need to callrender()manually.
❌ Negative Pattern: Do not put fetch logic, state mutation, and DOM updates all inside a single event handler function. It becomes impossible to test, reuse, or debug any individual part. Separate concerns even if the code is small.
Common Mistakes
Mistake 1: Fetching inside the render function
Triggering network requests from the render loop.
function render() {
fetch("/api/posts").then(r => r.json()).then(data => {
// render data — but this runs a new fetch every render!
});
}
Fetch once in an initialization function. The render function should only read state, never trigger side effects like network requests.
Mistake 2: Not resetting loading/error state before a new fetch
Leaving stale error messages visible when the user retries.
async function reload() {
// forgot: state.error = null; state.loading = true;
const response = await fetch(url);
// ...
}
Always reset loading and error at the start of each fetch so the UI accurately reflects the current operation, not a previous one.
Mistake 3: Filtering the original array instead of the stored data
Modifying state.posts during filter instead of computing a derived result.
function applyFilter(query) {
state.posts = state.posts.filter(p => p.title.includes(query)); // destroys data!
}
Filtering state.posts in place permanently removes items — you cannot un-filter. Always keep the full dataset in state and compute filtered views separately, as getFilteredPosts() does.
Quick Recap
- A data-driven UI separates three layers: fetch (get data), state (track current data), and render (show state to the user)
- State is the single source of truth — all user interactions update state, and the render function reads state to rebuild the UI
- Derived data (filtered lists, computed counts) is calculated at render time from state, never stored in state
- Error, loading, and empty states must all be handled explicitly — the render function should have a clear branch for each condition