Tutorial: Multi-Store Neutrix App (Pomodoro + Enhanced Store)
Project Setup
Create a new React + TypeScript project
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)
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:
store
: the underlying store (withget
,set
,action
, etc.)useStore
: a hook to subscribe to stateProvider
: 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.
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.
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
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
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
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
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
// 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>
);
}
//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!