In 2026, as new JavaScript tools continue to multiply, I challenged myself to build a flashcard app with plain JS — no framework, no build tool, no virtual DOM. What started as a simple study sidebar became a crash course in front-end architecture. The biggest lesson wasn’t how to flip cards or track progress; it was about separating logic, rendering, and state. This beginner walkthrough covers that process, the mistakes I made, and the exact mental model that kept the project from collapsing.
Why Plain JavaScript Still Matters for a Flashcard App
Frameworks like React and Vue are great, but they hide the fundamental relationships between data and UI. Building a flashcard app with plain JS forces you to confront those relationships directly. You have to decide where data lives, how the UI reacts, and how user interactions modify that data. By the time I finished, I understood what state really is and why “unidirectional data flow” isn’t just a buzzword — it’s a survival strategy.
If you’ve ever opened a codebase and felt lost because the HTML, CSS, and JS are tangled together, this walkthrough is for you. We’ll build a minimal but working flashcard app in stages, always keeping the three layers separate.
The Three Pillars: State, Logic, and Rendering
Before touching the DOM, I defined three distinct responsibilities in my flashcard app. This is the heart of the separation.
State: The Single Source of Truth
State is a plain object that describes everything that can change in the app. For a flashcard app, that includes the list of cards, the current card’s index, whether the card is flipped, and maybe the user’s answer streak. No HTML element holds any of this information. The state object is the only place these values live.
const state = {
cards: [
{ question: "What is 2+2?", answer: "4", hint: "A small even number" },
{ question: "What is 10/2?", answer: "5", hint: "Half of ten" }
],
currentIndex: 0,
isFlipped: false,
score: 0
};
Logic: Pure Functions and Event Handlers
Logic covers every action in the app, from flipping a card to moving to the next one. In separation, these are functions that take the current state as input and return a new state — or a new state plus a side effect. They never manipulate the DOM directly. That job belongs to rendering.
function flipCard(currentState) {
return { ...currentState, isFlipped: !currentState.isFlipped };
}
Notice how this function is pure: it doesn’t change the original state, it returns a new one. That makes testing and debugging straightforward.
Rendering: Functions That Paint the UI
Rendering takes the state object and turns it into HTML. In a separated architecture, there is a central render() function that reads state and updates the necessary parts of the DOM. It never changes state. If a user clicks a button, that click is passed to a logic function, which returns updated state, and then render() is called again. This simple loop is the whole app.
function render() {
const card = state.cards[state.currentIndex];
document.getElementById('question').textContent = card.question;
document.getElementById('answer').textContent = card.answer;
document.getElementById('card').classList.toggle('flipped', state.isFlipped);
}
Designing the Flashcard Data Model
A good data model is the foundation of separation. I started with an array of objects. Each card had a question, an answer, and a hint. The state also tracked the current position and flip status. This model was simple, but it supported everything I needed for a working flashcard app.
- Card: question, answer, hint, maybe tags or difficulty
- App state: currentIndex, isFlipped, score, cards array
- Derived data: progress percentage, whether done
Keeping the data structure plain made it easy to save to localStorage later. If the data had been scattered across DOM attributes, persistence would have been a nightmare.
Handling User Interactions Without a Framework
In my first attempt, I gave every button an onclick attribute that directly manipulated DOM elements. It was a mess. The key was to route every interaction through a single event listener layer that calls a logic function and then re-renders.
document.getElementById('flip-btn').addEventListener('click', () => {
const updatedState = flipCard(state);
Object.assign(state, updatedState);
render();
});
This pattern makes it obvious what happens when a user clicks. First, the state is updated. Second, the DOM is re-rendered. No other code reads or writes elements in between. That is the essence of separating logic and rendering.
For the “Next” button, I created a nextCard(state) function that checked the boundary and either advanced the index or wrapped around. Again, the event handler did nothing but delegate to a logic function.
Rendering Efficiency: Updating Only What Needs to Change
Once I had a basic render function, I noticed the whole card area was being rebuilt with every click. That works, but it’s sloppy. A better approach is to split rendering into smaller functions: one for the card text, one for the progress bar, one for the button states. Each function compares the new state with something it cached from the last render, and only touches the DOM when necessary.
function renderQuestion(card) {
const el = document.getElementById('question');
if (el.textContent !== card.question) el.textContent = card.question;
}
This minimal update approach avoids unnecessary browser work and makes the app feel snappier. It also makes the separation more obvious: the renderer knows exactly what part of the DOM corresponds to which part of state.
Persisting State with localStorage
A flashcard app is much more useful if it remembers where you left off. With a separated state object, persistence is trivial. I wrote a saveState() function that serialized the state to localStorage every time it changed, and a loadState() function that retrieved it when the page loaded.
function saveState() {
localStorage.setItem('flashcards', JSON.stringify(state));
}
function loadState() {
const saved = localStorage.getItem('flashcards');
return saved ? JSON.parse(saved) : initialState;
}
Since all the data is already in one object, there’s no need to scrape the DOM for values. That’s the payoff of keeping state separate from rendering.
Using a Simple Observer to Decouple Logic and Rendering
To make the interaction even cleaner, I added a tiny observer pattern. Logic functions don’t call render() directly; they emit an event. The renderer subscribes to that event. This means logic doesn’t need to know anything about the DOM, and rendering doesn’t need to know about the actions.
class Store {
constructor(initialState) { this.state = initialState; this.listeners = []; }
setState(newState) { this.state = newState; this.listeners.forEach(fn => fn(this.state)); }
subscribe(fn) { this.listeners.push(fn); }
}
This is a minimal version of what Redux or Zustand does. For a flashcard app, it gave me the benefit of a predictable data flow without the overhead of a library. The code became even easier to reason about because logic and rendering were completely unaware of each other.
Debugging a Separated Architecture
The biggest practical benefit of separating logic, rendering, and state was debugging. If a card flipped visually but the answer text didn’t change, I knew the bug was in the renderer. If the next button skipped a card, the problem was in the nextCard logic. I never had to search through a long file with interwoven DOM commands. The separation created natural boundaries that guided me straight to the bug.
Another advantage was easier unit testing. I could test the flip logic by calling flipCard(state) and asserting the returned state was flipped, without ever loading an HTML page. That’s a huge win for a beginner project that starts to grow.
Beginner Takeaways and Common Pitfalls
If you’re planning to build your own flashcards with plain JS, keep these lessons in mind:
- Don’t skip the data model. Spend time designing the state object first. It must contain everything the UI needs.
- Keep logic pure whenever possible. Pure functions that return a new state are easier to test and reuse.
- Resist the urge to update the DOM inside an event handler. Call a logic function, update state, then render.
- Split your render function. Large monolithic render functions lead to wasted DOM updates.
- Plan for state persistence from the start. It’s easier to add localStorage if the state is already a neat object.
A common pitfall is to overload the state object with derived data. For example, storing “progressPercent” when you can calculate it from currentIndex and cards.length. Derived values should be computed in the renderer, not stored in state. This keeps the state consistent and avoids synchronization bugs.
A Minimal, Maintainable Flashcard App
By the end of this build, I had a flashcard app that ran entirely in the browser, used a single HTML page, and weighed less than a typical framework bundle. But the real result was a clearer mental model of how front-end applications work. When I finally tried React after this experiment, all the concepts — state, props, render cycles, hooks — already made sense because I had built them by hand, in a simpler form.
Separating logic, rendering, and state is not just an architectural fad. It’s a practical way to keep a project like this flashcard app under control. Whether you’re building a study tool or a full web application, the same principles apply. Start with plain JS. Make a mess. Then separate the pieces and feel the mess disappear.
Building a flashcard app with plain JS and a clean separation of concerns taught me more than any framework tutorial ever did. If you’re starting your own beginner walkthrough, make the same mistake I did: build first, then separate. You’ll learn why each layer matters, and you’ll have a solid foundation for exploring modern tools with confidence.
