โ† Back to DevBytes

State Management in Parcel: Patterns and Libraries

Introduction to State Management in Parcel Applications

Parcel is a zero-configuration web application bundler that has gained popularity for its simplicity and speed. While Parcel handles the bundling, transpilation, and asset management of your application, it does not prescribe how you should manage application state. This leaves developers free to choose the state management patterns and libraries that best fit their project. In this tutorial, we will explore what state management means in the context of Parcel-bundled applications, why it matters, and how to implement it effectively using various patterns and libraries.

What Is State Management?

State management refers to the way an application stores, updates, and shares data across its components or modules. In modern web applications, state can include user input, server responses, UI configuration, authentication tokens, and more. Without a coherent strategy, state tends to scatter across the application, leading to bugs, inconsistent UI, and difficult maintenance.

In a Parcel-bundled application, state management is especially relevant because Parcel supports a wide range of frameworks โ€” React, Vue, Svelte, Preact, and vanilla JavaScript โ€” each with its own conventions. The bundler itself is agnostic to your state approach, which means you must make deliberate architectural decisions.

Why State Management Matters

Setting Up a Parcel Project

Before diving into state management patterns, let us set up a minimal Parcel project. Ensure you have Node.js installed, then create a new directory and initialize the project.

mkdir parcel-state-demo
cd parcel-state-demo
npm init -y
npm install --save-dev parcel
npm install react react-dom

Create a source directory and an entry HTML file:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Parcel State Management Demo</title>
</head>
<body>
  <div id="root"></div>
  <script type="module" src="https://agentechip.com/src/index.js"></script>
</body>
</html>

Add a start script to your package.json:

{
  "scripts": {
    "start": "parcel serve src/index.html",
    "build": "parcel build src/index.html"
  }
}

Now you are ready to explore different state management approaches.

Pattern 1: Local Component State

The simplest form of state management is local component state. This is appropriate for small applications or for state that is only relevant to a single component. In React, this is typically handled with the useState hook.

import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
    </div>
  );
}

const root = createRoot(document.getElementById('root'));
root.render(<Counter />);

Local state is easy to understand and requires no additional dependencies. However, it does not scale well when multiple components need to share or synchronize the same data.

Pattern 2: Lifting State Up

When two or more components need access to the same state, a common pattern is to lift that state to their nearest common ancestor. The ancestor holds the state and passes it down via props, along with callback functions to update it.

import React, { useState } from 'react';

function TemperatureInput({ temperature, onTemperatureChange }) {
  return (
    <fieldset>
      <legend>Enter temperature:</legend>
      <input
        value={temperature}
        onChange={(e) => onTemperatureChange(e.target.value)}
      />
    </fieldset>
  );
}

function Calculator() {
  const [temperature, setTemperature] = useState('');

  return (
    <div>
      <TemperatureInput
        temperature={temperature}
        onTemperatureChange={setTemperature}
      />
      <p>Boiling: {Number(temperature) >= 100 ? 'Yes' : 'No'}</p>
    </div>
  );
}

This pattern works for moderately complex component trees but can lead to "prop drilling" โ€” passing props through many intermediate components that do not use the data themselves.

Pattern 3: Context API

React's Context API provides a way to share state across the component tree without prop drilling. It is built into React, so no additional libraries are required. Context is ideal for themes, user authentication, locale preferences, and other global concerns.

import React, { createContext, useContext, useState } from 'react';

const AuthContext = createContext(null);

function AuthProvider({ children }) {
  const [user, setUser] = useState(null);

  const login = (username) => setUser({ username });
  const logout = () => setUser(null);

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

function UserProfile() {
  const { user, login, logout } = useContext(AuthContext);

  if (!user) {
    return <button onClick={() => login('alice')}>Log In</button>;
  }

  return (
    <div>
      <p>Welcome, {user.username}!</p>
      <button onClick={logout}>Log Out</button>
    </div>
  );
}

function App() {
  return (
    <AuthProvider>
      <UserProfile />
    </AuthProvider>
  );
}

While Context is powerful, it has a known limitation: any change to the context value causes all consuming components to re-render. For high-frequency updates, a dedicated state management library may perform better.

Pattern 4: Redux Toolkit

Redux is one of the most established state management libraries in the React ecosystem. Redux Toolkit (RTK) is the official, opinionated way to write Redux logic with less boilerplate. It works seamlessly with Parcel because Parcel automatically handles the necessary transpilation and dependency bundling.

Install the required packages:

npm install @reduxjs/toolkit react-redux

Create a slice that defines your state and reducers:

// src/features/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    incremented: (state) => {
      state.value += 1;
    },
    decremented: (state) => {
      state.value -= 1;
    },
    addedBy: (state, action) => {
      state.value += action.payload;
    },
  },
});

export const { incremented, decremented, addedBy } = counterSlice.actions;
export default counterSlice.reducer;

Configure the store and connect it to your application:

// src/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './features/counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});
// src/index.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider, useSelector, useDispatch } from 'react-redux';
import { store } from './store';
import { incremented, decremented, addedBy } from './features/counterSlice';

function Counter() {
  const count = useSelector((state) => state.counter.value);
  const dispatch = useDispatch();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => dispatch(incremented())}>+</button>
      <button onClick={() => dispatch(decremented())}>-</button>
      <button onClick={() => dispatch(addedBy(10))}>+10</button>
    </div>
  );
}

const root = createRoot(document.getElementById('root'));
root.render(
  <Provider store={store}>
    <Counter />
  </Provider>
);

Redux Toolkit uses Immer internally, which allows you to write "mutating" logic in reducers while keeping the state updates immutable. This dramatically reduces boilerplate compared to classic Redux.

Pattern 5: Zustand

Zustand is a lightweight, hook-based state management library that avoids much of the boilerplate associated with Redux. It is an excellent choice for Parcel projects that want simplicity without sacrificing power.

npm install zustand
// src/store.js
import { create } from 'zustand';

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

export default useStore;
// src/index.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import useStore from './store';

function Counter() {
  const { count, increment, decrement, reset } = useStore();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

const root = createRoot(document.getElementById('root'));
root.render(<Counter />);

Zustand also supports selecting slices of state to prevent unnecessary re-renders:

const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);

Pattern 6: Jotai for Atomic State

Jotai takes an atomic approach to state management. Instead of a single store, you define small, independent units of state called atoms. Components subscribe only to the atoms they need, which results in highly optimized re-renders.

npm install jotai
// src/atoms.js
import { atom } from 'jotai';

export const countAtom = atom(0);
export const doubleCountAtom = atom((get) => get(countAtom) * 2);
// src/index.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import { useAtom } from 'jotai';
import { countAtom, doubleCountAtom } from './atoms';

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const [double] = useAtom(doubleCountAtom);

  return (
    <div>
      <p>Count: {count}</p>
      <p>Double: {double}</p>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
    </div>
  );
}

const root = createRoot(document.getElementById('root'));
root.render(<Counter />);

Derived atoms, like doubleCountAtom above, automatically recompute when their dependencies change. This makes Jotai particularly well-suited for applications with complex interdependencies between pieces of state.

Pattern 7: State Machines with XState

For applications with complex, event-driven logic, state machines offer a rigorous way to model state transitions. XState is a popular library for creating finite state machines and statecharts. It integrates well with Parcel and works with or without a framework.

npm install xstate @xstate/react
// src/toggleMachine.js
import { createMachine } from 'xstate';

export const toggleMachine = createMachine({
  id: 'toggle',
  initial: 'inactive',
  states: {
    inactive: {
      on: { TOGGLE: 'active' },
    },
    active: {
      on: { TOGGLE: 'inactive' },
    },
  },
});
// src/index.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import { useMachine } from '@xstate/react';
import { toggleMachine } from './toggleMachine';

function Toggle() {
  const [state, send] = useMachine(toggleMachine);

  return (
    <button onClick={() => send({ type: 'TOGGLE' })}>
      {state.matches('active') ? 'Turn Off' : 'Turn On'}
    </button>
  );
}

const root = createRoot(document.getElementById('root'));
root.render(<Toggle />);

State machines make impossible states impossible. By explicitly defining every valid state and transition, you eliminate an entire class of bugs related to invalid or unexpected state combinations.

Pattern 8: Vanilla JavaScript with Signals

If you are building a Parcel application without a framework, or if you want framework-agnostic reactivity, signals are a compelling option. The @preact/signals-core package provides a tiny, dependency-free signals implementation.

npm install @preact/signals-core
// src/store.js
import { signal, effect } from '@preact/signals-core';

export const count = signal(0);

export function increment() {
  count.value++;
}

// React to changes
effect(() => {
  console.log('Count is now:', count.value);
});
// src/index.js
import { count, increment } from './store';

const button = document.createElement('button');
const display = document.createElement('p');

button.textContent = 'Increment';
button.addEventListener('click', increment);

const root = document.getElementById('root');
root.appendChild(display);
root.appendChild(button);

// Manual re-render using effect
import { effect } from '@preact/signals-core';
effect(() => {
  display.textContent = `Count: ${count.value}`;
});

Signals are extremely fast because they track dependencies automatically and only update the parts of the UI that depend on changed values. This makes them ideal for performance-sensitive applications.

Pattern 9: Async State with React Query

Server state is fundamentally different from client state. It is asynchronous, can become stale, and may need caching or synchronization. React Query (now part of TanStack Query) is purpose-built for managing server state.

npm install @tanstack/react-query
// src/index.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from '@tanstack/react-query';

const queryClient = new QueryClient();

function fetchUser() {
  return fetch('https://jsonplaceholder.typicode.com/users/1').then((res) =>
    res.json()
  );
}

function UserProfile() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', 1],
    queryFn: fetchUser,
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      <h3>{data.name}</h3>
      <p>{data.email}</p>
    </div>
  );
}

const root = createRoot(document.getElementById('root'));
root.render(
  <QueryClientProvider client={queryClient}>
    <UserProfile />
  </QueryClientProvider>
);

React Query handles caching, background refetching, stale data management, and optimistic updates. For most applications, combining React Query for server state with a lightweight client state library like Zustand provides a clean separation of concerns.

Best Practices for State Management in Parcel Projects

Separate Client State from Server State

Do not store server data in your global client store. Use a dedicated server-state library like React Query, SWR, or RTK Query. This keeps your client store focused on UI state and reduces duplication.

Start Simple and Evolve

Begin with local component state and the Context API. Only introduce a dedicated library when you feel the pain of prop drilling or when performance becomes an issue. Premature abstraction adds complexity without benefit.

Normalize Complex State

If your state includes lists of entities, store them as normalized dictionaries keyed by ID rather than arrays. This makes lookups, updates, and deletions more efficient.

// Instead of this:
const users = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' },
];

// Do this:
const users = {
  1: { id: 1, name: 'Alice' },
  2: { id: 2, name: 'Bob' },
};

Keep Reducers and Actions Pure

When using Redux or similar libraries, ensure your reducers are pure functions with no side effects. Handle asynchronous operations in thunks, sagas, or middleware layers.

Leverage Parcel's Hot Module Replacement

Parcel supports HMR out of the box. When developing state logic, HMR preserves component state across reloads, which speeds up your development loop. Ensure your state initialization code is idempotent so HMR does not create duplicate stores or subscriptions.

Use TypeScript for Type Safety

Parcel supports TypeScript without additional configuration. Adding types to your state definitions catches errors at compile time and improves developer experience.

// src/store.ts
import { create } from 'zustand';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
}

const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));

export default useCounterStore;

Test Your State Logic Independently

State logic should be decoupled from UI components so it can be tested in isolation. This is naturally easier with libraries like Redux and Zustand, where the store is a plain JavaScript object or function.

// src/store.test.js
import useCounterStore from './store';

test('increment increases count by 1', () => {
  const { increment, count } = useCounterStore.getState();
  const initial = count;
  increment();
  expect(useCounterStore.getState().count).toBe(initial + 1);
});

Choosing the Right Library

The following guidelines can help you select the appropriate state management approach for your Parcel project:

Conclusion

State management is one of the most important architectural decisions in any web application, and Parcel's framework-agnostic nature gives you the freedom to choose the approach that best fits your needs. Whether you start with simple local state and Context, adopt Redux Toolkit for a structured and scalable solution, reach for Zustand or Jotai for lightweight reactivity, or model complex flows with XState, the key is to match the tool to the problem. By separating client state from server state, keeping your logic pure and testable, and leveraging Parcel's fast bundling and HMR during development, you can build maintainable, performant applications that scale gracefully as they grow. Start simple, measure where complexity emerges, and introduce libraries deliberately โ€” your future self and your teammates will thank you for the clarity and predictability that a well-considered state management strategy brings.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles