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

> Master front-end state management with component state, Context API, custom hooks, Redux, or Pinia. Choose the right approach for your app ideas project.

- Repository: [Florin Pop/app-ideas](https://github.com/florinpop17/app-ideas)
- Tags: best-practices
- Published: 2026-02-27

---

**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`](https://github.com/florinpop17/app-ideas/blob/main/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`](https://github.com/florinpop17/app-ideas/blob/main/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.

```tsx
// 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>
  );
};

```

```tsx
// 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.

```js
// 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`](https://github.com/florinpop17/app-ideas/blob/main/Projects/2-Intermediate/GitHub-Profiles.md), Pinia provides a centralized store that handles async fetching, derived top-repository calculations, and theme persistence.

```ts
// 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;
    },
  },
});

```

```vue
<!-- 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`](https://github.com/florinpop17/app-ideas/blob/main/Projects/2-Intermediate/To-Do-App.md) and [`Projects/2-Intermediate/GitHub-Profiles.md`](https://github.com/florinpop17/app-ideas/blob/main/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.