Skip to content

Tutorial: Multi-Store Neutrix App (Pomodoro + Enhanced Store)

Project Setup

Create a new React + TypeScript project

bash
npm create vite@latest power-app -- --template react-ts
cd shop-app
npm install neutrix react-router-dom

Structure

.
├── App.css
├── App.tsx
├── Header.tsx
├── TaskList.tsx
├── ThemeSwitcher.tsx
├── assets
│   └── react.svg
├── components
│   └── Pomodoro.tsx
├── index.css
├── main.tsx
├── store
│   ├── enhancedStore.ts
│   └── pomodoroStore.ts
├── styles.css
└── vite-env.d.ts

Introduction

This tutorial will demonstrate how to structure React applications with multiple stores, handle user interactions, and manage complex state updates in a clean, organized way.

Tutorial

1. Setting up the enhanced store: enhancedStore.ts

The “Enhanced Store” will manage:

  • User information such as id, name, etc.
  • Tasks such as list of tasks, add tasks, etc.
  • UI state (theme, loading)
typescript
import { createNeutrixStore } from 'neutrix';
import type { Middleware } from 'neutrix';

export interface Task {  
  id: number;
  title: string;
  done: boolean;
}

export interface EnhancedState {
  user: { id: string; name: string } | null;
  tasks: Task[];
  tasksCount?: () => number;
  ui: {
    theme: 'light' | 'dark';
    loading: boolean;
  };
}

const loggerMiddleware: Middleware = {
  onSet: (path, value, prevValue) => {
    console.log(`[Logger] ${path}: ${prevValue} → ${value}`);
    return value;
  },
  onError: (error) => {
    console.error('[Logger] Store error:', error);
  }
};

export const {
  store: enhancedStore,
  useStore: useEnhancedStore,
  Provider
} = createNeutrixStore<EnhancedState>(
  {
    user: null,
    tasks: [],
    ui: {
      theme: 'light',
      loading: false
    }
  },
  {
    provider: true, // We want a provider-based store
    devTools: true,       
    name: 'enhanced-store',
    concurrency: true,    
    persist: true  
  }
);

enhancedStore.use(loggerMiddleware);

export const loginAction = enhancedStore.action(async (st, user: { id: string; name: string }) => {
  st.set('ui', { ...st.get('ui'), loading: true });
  // simulate an async request
  await new Promise(res => setTimeout(res, 300));
  st.set('user', user);
  st.set('ui', { ...st.get('ui'), loading: false });
});

export const addTaskAction = enhancedStore.action(async (st, title: string) => {
  st.set('ui', { ...st.get('ui'), loading: true });
  await new Promise(res => setTimeout(res, 500));
  const tasks = st.get('tasks');
  tasks.push({ id: Date.now(), title, done: false });
  st.set('tasks', [...tasks]);
  st.set('ui', { ...st.get('ui'), loading: false });
});

export function logout() {
  enhancedStore.batch([
    ['user', null]
    // tasks are persisted
  ]);
}

enhancedStore.computed('tasksCount', (s) => s.tasks.length);
  • createNeutrixStore returns an object with:
  1. store: the underlying store (with get, set, action, etc.)
  2. useStore: a hook to subscribe to state
  3. Provider: optional React component for advanced usage
  • provider: true indicates we want to wrap part of the app with <Provider>

  • enhancedStore.use(loggerMiddleware) adds a middleware that logs changes

2. The Pomodoro Store: pomodoroStore.ts

The Pomodoro store tracks:

  • isRunning: Are we currently counting down? mode: 'work' or 'break'
  • timeLeft: how many seconds remain
  • stats: how many completed Pomodoros, total work time, etc.
typescript
import { createNeutrixStore } from 'neutrix';
import type { Middleware, Store } from 'neutrix';

export interface PomodoroState {
  isRunning: boolean;
  mode: 'work' | 'break';
  timeLeft: number;
  currentTaskId: number | null;
  stats: {
    completedPomodoros: number;
    totalWorkTime: number;
    totalBreakTime: number;
  };
}

const loggerMiddleware: Middleware = {
  onSet: (path, value, prevValue) => {
    console.log(`[Pomodoro Logger] ${path}: ${prevValue} → ${value}`);
    return value;
  }
};

export const {
  store: pomodoroStore,
  useStore: usePomodoroStore
} = createNeutrixStore<PomodoroState>(
  {
    isRunning: false,
    mode: 'work',
    timeLeft: 25 * 60,
    currentTaskId: null,
    stats: {
      completedPomodoros: 0,
      totalWorkTime: 0,
      totalBreakTime: 0
    }
  },
  {
    provider: true,
    devTools: true,
    name: 'pomodoro-store',
    persist: true
  }
);

// attach logger
pomodoroStore.use(loggerMiddleware);

export const startPomodoro = pomodoroStore.action(async (st: Store<PomodoroState>, taskId: number) => {
  st.set('isRunning', true);
  st.set('currentTaskId', taskId);
});

export const pausePomodoro = pomodoroStore.action(async (st: Store<PomodoroState>) => {
  st.set('isRunning', false);
});

export const completePomodoro = pomodoroStore.action(async (st: Store<PomodoroState>) => {
  const stats = st.get('stats');
  const mode = st.get('mode');
  
  if (mode === 'work') {
    stats.completedPomodoros++;
    stats.totalWorkTime += 25 * 60;
    st.set('mode', 'break');
    st.set('timeLeft', 5 * 60); // 5-min break
  } else {
    stats.totalBreakTime += 5 * 60;
    st.set('mode', 'work');
    st.set('timeLeft', 25 * 60);
  }
  
  st.set('stats', { ...stats });
  st.set('isRunning', false);
  st.set('currentTaskId', null);
})

3. Combining Stores in <NeutrixProvider>: App.tsx

We want to pass both enhancedStore and pomodoroStore to the same app. We do that with one <NeutrixProvider> that has a stores prop.

typescript
import { NeutrixProvider } from 'neutrix';
import { enhancedStore } from './store/enhancedStore';
import { pomodoroStore } from './store/pomodoroStore';
import { Header } from './Header';
import { ThemeSwitcher } from './ThemeSwitcher';
import { Pomodoro } from './components/Pomodoro';
import { TaskList } from './TaskList';

export default function App() {
  return (
    // note these 2 stores. you can use a single store too
    <NeutrixProvider
      stores={{
        mainStore: enhancedStore,
        pomodoroStore: pomodoroStore
      }}
    >
      <div className="app">
        <Header />
        <ThemeSwitcher />
        <Pomodoro />
        <TaskList />
      </div>
    </NeutrixProvider>
  );
}

Each store is separate, but we can feed them all to <NeutrixProvider> so that the child components can read from them

4. Creating the Header: Header.tsx

typescript
import { useEnhancedStore, loginAction, logout } from './store/enhancedStore';
import type { EnhancedState } from './store/enhancedStore';

export function Header() {
  const user = useEnhancedStore((s: EnhancedState) => s.user);
  const loading = useEnhancedStore((s: EnhancedState) => s.ui.loading);

  async function handleLogin() {
    await loginAction({ id: 'abc', name: 'Alice' });
  }

  return (
    <header style={{ borderBottom: '1px solid #ccc', padding: '1rem' }}>
      {user ? (
        <>
          <span>Welcome, {user.name}!</span>
          <button onClick={logout} style={{ marginLeft: '1rem' }}>
            Logout
          </button>
        </>
      ) : (
        <button onClick={handleLogin} disabled={loading}>
          {loading ? 'Logging in...' : 'Login'}
        </button>
      )}
    </header>
  );
}
  • useEnhancedStore is the hook from enhancedStore.
  • We read user and ui.loading to show a “Login” or “Welcome” message. Do note that we are not doing an actual login. Actual authentication and other processes can be done on your side if you so choose. This is just an example
  • We call the loginAction(...) to simulate logging in.

5. Creating the TaskList: TaskList.tsx

typescript
import { useState } from 'react';
import { useEnhancedStore, addTaskAction, type EnhancedState, type Task } from './store/enhancedStore';

export function TaskList() {
  const tasks = useEnhancedStore((s: EnhancedState) => s.tasks);
  const loading = useEnhancedStore((s: EnhancedState) => s.ui.loading);
  const theme = useEnhancedStore((s: EnhancedState) => s.ui.theme);

  const [title, setTitle] = useState('');

  async function handleAddTask() {
    if (!title.trim()) return;
    await addTaskAction(title.trim());
    setTitle('');
  }

  return (
    <div style={{
      padding: '2rem',
      maxWidth: '600px',
      margin: '0 auto',
      backgroundColor: theme === 'dark' ? '#2d2d2d' : '#ffffff',
      borderRadius: '12px',
      boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
      transition: 'all 0.3s ease'
    }}>
      <h2 style={{
        margin: '0 0 1.5rem 0',
        color: theme === 'dark' ? '#ffffff' : '#333333',
        fontSize: '1.75rem'
      }}>
      {tasks.length === 0 ? 'No Tasks' : `My Tasks (${tasks.length})`}
      </h2>
      
      <ul style={{
        listStyle: 'none',
        padding: 0,
        margin: '0 0 1.5rem 0'
      }}>
        {tasks.map((t: Task) => (
          <li 
            key={t.id} 
            onMouseEnter={e => e.currentTarget.style.transform = 'translateX(5px)'}
            onMouseLeave={e => e.currentTarget.style.transform = 'translateX(0)'}
            style={{
              padding: '0.75rem 1rem',
              marginBottom: '0.5rem',
              backgroundColor: theme === 'dark' ? '#3d3d3d' : '#f5f5f5',
              borderRadius: '8px',
              display: 'flex',
              justifyContent: 'space-between',
              alignItems: 'center',
              transition: 'transform 0.2s ease',
              cursor: 'pointer',
              color: theme === 'dark' ? '#ffffff' : '#333333'
            }}>
            <span>{t.title}</span>
            <span style={{
              padding: '0.25rem 0.75rem',
              borderRadius: '999px',
              fontSize: '0.875rem',
              backgroundColor: t.done 
                ? (theme === 'dark' ? '#2e5a2e' : '#dcf5dc')
                : (theme === 'dark' ? '#5a4a2e' : '#fff3dc'),
              color: t.done
                ? (theme === 'dark' ? '#90ee90' : '#166534')
                : (theme === 'dark' ? '#ffd700' : '#854d0e')
            }}>
              {t.done ? 'Done' : 'Pending'}
            </span>
          </li>
        ))}
      </ul>

      <div style={{
        display: 'flex',
        gap: '0.75rem'
      }}>
        <input
          value={title}
          onChange={e => setTitle(e.target.value)}
          placeholder="New task..."
          style={{
            flex: 1,
            padding: '0.75rem 1rem',
            borderRadius: '8px',
            border: theme === 'dark' ? '1px solid #4d4d4d' : '1px solid #e5e5e5',
            backgroundColor: theme === 'dark' ? '#3d3d3d' : '#ffffff',
            color: theme === 'dark' ? '#ffffff' : '#333333',
            outline: 'none',
            transition: 'border-color 0.2s ease'
          }}
          onFocus={e => e.target.style.borderColor = '#3b82f6'}
          onBlur={e => e.target.style.borderColor = theme === 'dark' ? '#4d4d4d' : '#e5e5e5'}
        />
        <button 
          onClick={handleAddTask} 
          disabled={loading}
          style={{
            padding: '0.75rem 1.5rem',
            borderRadius: '8px',
            border: 'none',
            backgroundColor: '#3b82f6',
            color: '#ffffff',
            fontWeight: '500',
            cursor: loading ? 'not-allowed' : 'pointer',
            opacity: loading ? 0.7 : 1,
            transition: 'all 0.2s ease'
          }}
          onMouseEnter={e => e.currentTarget.style.backgroundColor = '#2563eb'}
          onMouseLeave={e => e.currentTarget.style.backgroundColor = '#3b82f6'}
        >
          {loading ? 'Adding...' : 'Add Task'}
        </button>
      </div>
    </div>
  );
}

Just 2 more components to go..

6. Creating the Pomodoro component: Pomodoro.tsx

typescript
import { useEffect } from 'react';
import { useEnhancedStore } from '../store/enhancedStore';
import {
  usePomodoroStore,
  startPomodoro,
  pausePomodoro,
  completePomodoro,
  pomodoroStore
} from '../store/pomodoroStore';

export function Pomodoro() {
  // Read from Enhanced store
  const theme = useEnhancedStore(s => s.ui.theme);
  const tasks = useEnhancedStore(s => s.tasks);

  // Read from Pomodoro store
  const isRunning = usePomodoroStore(s => s.isRunning);
  const mode = usePomodoroStore(s => s.mode);
  const timeLeft = usePomodoroStore(s => s.timeLeft);
  const currentTaskId = usePomodoroStore(s => s.currentTaskId);
  const stats = usePomodoroStore(s => s.stats);

  useEffect(() => {
    let interval: NodeJS.Timeout;
    if (isRunning) {
      interval = setInterval(() => {
        if (timeLeft > 0) {
          pomodoroStore.set('timeLeft', timeLeft - 1);
        } else {
          completePomodoro();
        }
      }, 1000);
    }
    return () => clearInterval(interval);
  }, [isRunning, timeLeft]);

  // if there's no tasks, ask the user to add one
  if (tasks.length === 0) {
    return <div>...some UI prompting them to create a task</div>;
  }

  const formatTime = (secs: number) => {
    const m = Math.floor(secs / 60);
    const s = secs % 60;
    return `${m}:${s.toString().padStart(2, '0')}`;
  };

  // use the first task or currentTaskId
  const currentTask = tasks.find(t => t.id === currentTaskId);

  return (
    <div style={{ maxWidth: '600px', margin: 'auto' }}>
      <h2>{mode === 'work' ? 'Work Time' : 'Break Time'}</h2>
      <div>{formatTime(timeLeft)}</div>
      {currentTask && <div>Working on: {currentTask.title}</div>}

      {!isRunning ? (
        <button onClick={() => startPomodoro(currentTaskId || tasks[0].id)}>
          Start {mode === 'work' ? 'Working' : 'Break'}
        </button>
      ) : (
        <button onClick={() => pausePomodoro()}>
          Pause
        </button>
      )}

      <div>
        <h3>Stats</h3>
        <div>Completed: {stats.completedPomodoros}</div>
        <div>Total Work: {Math.round(stats.totalWorkTime / 60)} minutes</div>
        <div>Total Break: {Math.round(stats.totalBreakTime / 60)} minutes</div>
      </div>
    </div>
  );
}

This demonstrates how you can read from both the Enhanced store (for tasks/theme) and the Pomodoro store (for the countdown logic).

7. Toggling dark/light screen: ThemeSwitcher.tsx

typescript
import { useEnhancedStore, enhancedStore } from './store/enhancedStore';
import { useEffect } from 'react';

export function ThemeSwitcher() {
  const theme = useEnhancedStore(s => s.ui.theme);

  useEffect(() => {
    document.body.style.backgroundColor = theme === 'dark' ? '#1a1a1a' : '#fff';
    document.body.style.color = theme === 'dark' ? '#fff' : '#000';
  }, [theme]);

  function toggleTheme() {
    const next = theme === 'light' ? 'dark' : 'light';
    const ui = enhancedStore.get('ui');
    enhancedStore.set('ui', { ...ui, theme: next });
  }

  return (
    <div>
      <p>Current theme: {theme}</p>
      <button onClick={toggleTheme}>
        Toggle Theme
      </button>
    </div>
  );
}

8. Finally, we tie it all together: App.tsx + main.tsx

typescript
// App.tsx
import { NeutrixProvider } from 'neutrix';
import { enhancedStore } from './store/enhancedStore';
import { pomodoroStore } from './store/pomodoroStore';

import { Header } from './Header';
import { TaskList } from './TaskList';
import { ThemeSwitcher } from './ThemeSwitcher';
import { Pomodoro } from './components/Pomodoro';

export default function App() {
  return (
    <NeutrixProvider stores={{
      mainStore: enhancedStore,
      pomodoroStore: pomodoroStore
    }}>
      <div className="app">
        <Header />
        <ThemeSwitcher />
        <Pomodoro />
        <TaskList />
      </div>
    </NeutrixProvider>
  );
}
typescript
//main.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';

createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

Thats it!

Recap:

Each store has its own useStore hook: * useEnhancedStore → reading/writing user, tasks, UI * usePomodoroStore → reading/writing pomodoro timer

  • Both are provider-based (provider: true), we pass them into <NeutrixProvider> as an object.
  • Components can subscribe to whichever store they need.
  • We use actions for complex/async updates, and computed for derived values.
  • The theme toggles, the pomodoro countdown, and the task list are all in separate files, but they share the same global state management via Neutrix.

That’s it! You now have a multi-store Neutrix application with a pomodoro timer, an enhanced store for tasks/themes/users, and examples of hooking them together with <NeutrixProvider>.

Have fun coding!