How to Handle State Management in Front-End Projects from the App Ideas Collection

Use local component state for simple UI elements, Context API or custom hooks for shared data across components, and reducer-based stores like Redux or Pinia for complex interactions requiring persistence or API integration.

The florinpop17/app-ideas repository provides detailed specifications for front-end projects—including the To-Do App and GitHub Profiles challenges—but leaves architectural decisions to the developer. Implementing robust state management is the key to turning these markdown requirements into scalable, maintainable applications that handle user interactions and data persistence gracefully.

Choosing the Right State Management Strategy

Selecting an approach depends on the complexity defined in the project specifications. For intermediate-tier projects like those found in Projects/2-Intermediate/, match your strategy to the feature set:

  • Local component state (useState in React, data in Vue, or plain objects) – Ideal for simple UI with a handful of values, such as form inputs or toggle buttons. This keeps state where it is used with minimal boilerplate.

  • Context API or custom hooks (React) / Provide/Inject (Vue 3) – Best when multiple components share the same data, such as a To-Do list paired with a filter UI. This avoids prop-drilling while remaining lightweight.

  • Reducer-based state (useReducer or Redux Toolkit) / Vuex or Pinia – Required for complex interactions like undo/redo, async data fetching with caching, or pagination (e.g., the GitHub API integration in Projects/2-Intermediate/GitHub-Profiles.md). This provides predictable state transitions and centralized logic.

  • Persisted stores (Redux Toolkit with redux-persist, Pinia with plugins, or manual sync) – Essential when implementing the "store in browser" bonus features specified in Projects/2-Intermediate/To-Do-App.md. These guarantee data survival across page reloads.

  • Plain JavaScript with module pattern – Suitable for very small apps where adding a library feels heavy. This encapsulates state without extra dependencies while still supporting localStorage persistence.

Core Architectural Concepts

Regardless of which project you build from the collection, these principles ensure your state logic remains maintainable:

  • Single source of truth – Derive the entire UI from a single state object. Implement this with one React useState or useReducer hook at the top level, or a centralized Vuex/Pinia store.

  • Immutability – Never mutate state directly; always create new objects. Use the spread operator ({...prev, newItem}) or produce from Immer to prevent accidental side effects.

  • Derived state – Compute UI-only data (such as filtered lists or completion counts) from the base state rather than storing it separately. Calculate these values inside selectors or computed properties.

  • Side-effects isolation – Keep API calls, localStorage writes, and timers outside pure state updates. Use useEffect in React, watchEffect in Vue, or a dedicated service layer to handle asynchronous operations.

  • Modularity – Split state logic per feature (e.g., todos, theme, auth). Combine multiple reducers with combineReducers or use Vuex modules to keep concerns separated.

Implementation Examples by Framework

React: To-Do App with Context and useReducer

For the To-Do App specification, combine useReducer for predictable updates with React Context to avoid prop-drilling, plus useEffect to satisfy the persistence bonus feature.

// src/state/TodoContext.tsx
import React, { createContext, useReducer, useEffect, ReactNode } from "react";

type Todo = { id: string; text: string; done: boolean };
type State = { todos: Todo[] };
type Action =
  | { type: "ADD"; payload: string }
  | { type: "TOGGLE"; payload: string }
  | { type: "REMOVE"; payload: string };

const initialState: State = { todos: [] };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "ADD":
      const newTodo = { id: crypto.randomUUID(), text: action.payload, done: false };
      return { todos: [...state.todos, newTodo] };
    case "TOGGLE":
      return {
        todos: state.todos.map(t =>
          t.id === action.payload ? { ...t, done: !t.done } : t
        ),
      };
    case "REMOVE":
      return { todos: state.todos.filter(t => t.id !== action.payload) };
    default:
      return state;
  }
}

export const TodoContext = createContext<{
  state: State;
  dispatch: React.Dispatch<Action>;
}>({ state: initialState, dispatch: () => null });

export const TodoProvider = ({ children }: { children: ReactNode }) => {
  const [state, dispatch] = useReducer(reducer, initialState, init => {
    const persisted = localStorage.getItem("todos");
    return persisted ? { todos: JSON.parse(persisted) } : init;
  });

  useEffect(() => {
    localStorage.setItem("todos", JSON.stringify(state.todos));
  }, [state.todos]);

  return (
    <TodoContext.Provider value={{ state, dispatch }}>{children}</TodoContext.Provider>
  );
};
// src/components/TodoApp.tsx
import React, { useContext, useState } from "react";
import { TodoContext } from "../state/TodoContext";

export const TodoApp = () => {
  const { state, dispatch } = useContext(TodoContext);
  const [input, setInput] = useState("");

  const add = (e: React.FormEvent) => {
    e.preventDefault();
    if (input.trim()) {
      dispatch({ type: "ADD", payload: input.trim() });
      setInput("");
    }
  };

  return (
    <>
      <form onSubmit={add}>
        <input
          placeholder="What needs to be done?"
          value={input}
          onChange={e => setInput(e.target.value)}
        />
      </form>

      <ul>
        {state.todos.map(t => (
          <li key={t.id}>
            <span
              style={{ textDecoration: t.done ? "line-through" : "none", cursor: "pointer" }}
              onClick={() => dispatch({ type: "TOGGLE", payload: t.id })}
            >
              {t.text}
            </span>
            <button onClick={() => dispatch({ type: "REMOVE", payload: t.id })}>✕</button>
          </li>
        ))}
      </ul>
    </>
  );
};

Vanilla JavaScript: Module Pattern Approach

When building smaller apps without a framework, encapsulate state within an Immediately Invoked Function Expression (IIFE) to prevent external mutation while still supporting persistence.

// todo.js
const TodoApp = (() => {
  const STORAGE_KEY = "todoItems";

  const state = {
    items: JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"),
  };

  const render = () => {
    const list = document.getElementById("todo-list");
    list.innerHTML = "";
    state.items.forEach(({ id, text, done }) => {
      const li = document.createElement("li");
      li.textContent = text;
      li.style.textDecoration = done ? "line-through" : "none";
      li.onclick = () => toggle(id);
      const rm = document.createElement("button");
      rm.textContent = "✕";
      rm.onclick = (e) => {
        e.stopPropagation();
        remove(id);
      };
      li.appendChild(rm);
      list.appendChild(li);
    });
  };

  const add = (text) => {
    const id = crypto.randomUUID();
    state.items.push({ id, text, done: false });
    sync();
    render();
  };

  const toggle = (id) => {
    const item = state.items.find(i => i.id === id);
    if (item) item.done = !item.done;
    sync();
    render();
  };

  const remove = (id) => {
    state.items = state.items.filter(i => i.id !== id);
    sync();
    render();
  };

  const sync = () => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state.items));
  };

  return { add, render };
})();

// wiring in index.html
document.getElementById("todo-form").addEventListener("submit", e => {
  e.preventDefault();
  const input = document.getElementById("todo-input");
  if (input.value.trim()) {
    TodoApp.add(input.value.trim());
    input.value = "";
  }
});

TodoApp.render();

Vue 3: GitHub Profiles with Pinia

For the GitHub Profiles project in Projects/2-Intermediate/GitHub-Profiles.md, Pinia provides a centralized store that handles async fetching, derived top-repository calculations, and theme persistence.

// src/stores/profile.ts
import { defineStore } from "pinia";

export const useProfileStore = defineStore("profile", {
  state: () => ({
    username: "",
    profile: null as null | {
      avatar_url: string;
      login: string;
      followers: number;
      public_repos: number;
      topRepos: Array<{ name: string; stars: number; forks: number }>;
    },
    darkMode: false,
  }),
  actions: {
    async fetchProfile() {
      try {
        const res = await fetch(`https://api.github.com/users/${this.username}`);
        if (!res.ok) throw new Error("User not found");
        const data = await res.json();
        const reposRes = await fetch(`${data.repos_url}?per_page=100`);
        const repos = await reposRes.json();
        const topRepos = repos
          .sort((a: any, b: any) => b.stargazers_count + b.forks_count - (a.stargazers_count + a.forks_count))
          .slice(0, 4)
          .map((r: any) => ({
            name: r.name,
            stars: r.stargazers_count,
            forks: r.forks_count,
          }));
        this.profile = {
          avatar_url: data.avatar_url,
          login: data.login,
          followers: data.followers,
          public_repos: data.public_repos,
          topRepos,
        };
        localStorage.setItem("darkMode", JSON.stringify(this.darkMode));
        localStorage.setItem("lastUser", this.username);
      } catch (e) {
        alert(e);
      }
    },
    toggleDark() {
      this.darkMode = !this.darkMode;
      localStorage.setItem("darkMode", JSON.stringify(this.darkMode));
    },
    loadPersisted() {
      const mode = localStorage.getItem("darkMode");
      const user = localStorage.getItem("lastUser");
      this.darkMode = mode ? JSON.parse(mode) : false;
      if (user) this.username = user;
    },
  },
});
<!-- src/components/ProfileSearch.vue -->
<template>
  <div :class="{ dark: profileStore.darkMode }">
    <input v-model="profileStore.username" placeholder="GitHub username" />
    <button @click="profileStore.fetchProfile">Search</button>
    <button @click="profileStore.toggleDark">
      {{ profileStore.darkMode ? "Light" : "Dark" }} mode
    </button>

    <div v-if="profileStore.profile" class="card">
      <img :src="profileStore.profile.avatar_url" alt="avatar" />
      <h2>{{ profileStore.profile.login }}</h2>
      <p>Followers: {{ profileStore.profile.followers }}</p>
      <p>Repos: {{ profileStore.profile.public_repos }}</p>

      <h3>Top Repos</h3>
      <ul>
        <li v-for="repo in profileStore.profile.topRepos" :key="repo.name">
          {{ repo.name }} ★{{ repo.stars }} 🍴{{ repo.forks }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useProfileStore } from "@/stores/profile";
const profileStore = useProfileStore();
profileStore.loadPersisted();
</script>

<style scoped>
.dark { background: #222; color: #eee; }
</style>

Summary

  • Match your state management strategy to the project complexity: local state for simple UIs, Context for shared data, and reducers/Pinia for complex async logic.
  • Maintain a single source of truth and enforce immutability to prevent unpredictable side effects across components.
  • Isolate side effects like localStorage writes and API calls from pure state updates using effects or dedicated services.
  • Reference the specific requirements in Projects/2-Intermediate/To-Do-App.md and Projects/2-Intermediate/GitHub-Profiles.md to determine if you need persistence or theme management features.
  • Encapsulate state using modern patterns—React Context with useReducer, Vue Pinia stores, or vanilla JavaScript module patterns—to keep code maintainable as features grow.

Frequently Asked Questions

Should I use Redux for every project in the App Ideas collection?

No, Redux is only necessary for complex interactions requiring centralized logic, undo/redo functionality, or extensive middleware. For the To-Do App or Bin2Dec projects, useState or useReducer with Context provides sufficient state management without the boilerplate overhead.

How do I implement the localStorage persistence bonus feature correctly?

Sync state to localStorage inside a useEffect hook (React) or watchEffect (Vue) that triggers on state changes, and hydrate the initial state by reading from localStorage during store initialization. Always wrap localStorage access in try-catch blocks and parse JSON safely to handle private browsing modes or storage quotas.

Can I complete these projects without React or Vue?

Yes, the App Ideas specifications are framework-agnostic. Use the plain JavaScript module pattern with an IIFE to encapsulate state, or leverage web components with Lit or Stencil if you prefer standards-based solutions over framework-specific APIs.

When should I switch from useState to useReducer?

Switch when you manage more than three related state values, when state transitions depend on complex logic (like the toggle and remove operations in the To-Do App), or when you need to centralize update logic for easier testing and debugging. The reducer pattern also makes persistence and time-travel debugging simpler to implement.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →