A phase-first lifecycle abstraction for React.
react-phase helps you manage component lifecycle logic without directly depending on useEffect. Instead of scattering side effects across multiple effects, react-phase provides structured lifecycle phases with readable and controllable update execution — fully typed with TypeScript.
Managing side effects in React often becomes difficult because:
useEffectmixes multiple concerns- dependency arrays become hard to reason about
- lifecycle intent is unclear
- update conditions are limited to OR behavior
- async effects become messy
- complex effects become unreadable
react-phase provides:
- explicit lifecycle phases
- mount / unmount / update separation
- AND / OR dependency execution
- custom dependency matchers via
.when() - debounce & throttle support
- async phase orchestration
- request cancellation via
AbortSignal - retry handling
- full TypeScript support
npm install react-phase
pnpm add react-phase
yarn add react-phase
import { usePhase } from "react-phase";
function App({ user, token, search }: Props) {
const { onMount, onUnmount, onUpdate } = usePhase();
onMount(() => {
console.log("mounted");
});
onUnmount(() => {
console.log("cleanup");
});
onUpdate([user, token] as const, () => {
console.log("both changed");
}).and();
onUpdate([search] as const, () => {
console.log("searching...");
}).debounce(500);
return <div>Hello</div>;
}
react-phase organizes component side effects into explicit lifecycle phases.
| Phase | Purpose |
|---|---|
onMount |
Runs once after mount |
onUnmount |
Runs once before unmount |
onUpdate |
Runs when dependencies change |
Runs once after component mount. Supports optional cleanup via return value.
onMount(() => {
const timer = setInterval(tick, 1000);
return () => clearInterval(timer); // cleanup
});
Runs once before component unmount.
onUnmount(() => {
console.log("cleanup");
});
Runs when dependencies change. Returns a chainable PhaseController.
onUpdate([count] as const, () => {
console.log("count changed");
});
By default, updates run when any dependency changes.
onUpdate([user, token] as const, () => {
console.log("either changed");
});
// equivalent to:
onUpdate([user, token] as const, () => {
console.log("either changed");
}).or();
Run only when all dependencies change.
onUpdate([user, token] as const, () => {
console.log("both changed");
}).and();
Create fully custom dependency execution logic using .when().
.when() takes priority over .and() and .or().
onUpdate([price] as const, () => {
console.log("threshold crossed");
}).when((prev, current) => {
return prev[0] < 1000 && current[0] >= 1000;
});
Authentication example:
onUpdate([user, token] as const, () => {
console.log("authenticated");
}).when((prev, current) => {
const [prevUser, prevToken] = prev;
const [currentUser, currentToken] = current;
return !prevUser && !prevToken && !!currentUser && !!currentToken;
});
The matcher receives strongly-typed prev and current arrays matching the shape of the deps tuple.
Delay execution until dependency changes stop for a specified duration.
Useful for: search inputs, API calls, expensive computations, resize handlers.
onUpdate([search] as const, () => {
fetchResults(search);
}).debounce(500);
The update runs only after 500ms of inactivity.
Limit how frequently updates can execute.
Useful for: scroll events, mouse movement, rapid state updates, performance-sensitive operations.
onUpdate([scrollY] as const, () => {
console.log(scrollY);
}).throttle(200);
The update runs at most once every 200ms.
react-phase supports async lifecycle execution with built-in orchestration helpers.
onUpdate([userId] as const, async () => {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
});
When dependencies change rapidly, older async executions are automatically ignored — only the latest execution is applied.
Async phases receive an AbortSignal. When the component unmounts, dependencies change, or execution becomes stale, the previous request is aborted automatically.
onUpdate([userId] as const, async ({ signal }) => {
const response = await fetch(`/api/users/${userId}`, { signal });
const data = await response.json();
setUser(data);
});
Retries failed executions up to N times with exponential backoff.
onUpdate([userId] as const, async () => {
return fetchUser(userId);
}).retry(3);
onUpdate([userId] as const, async () => {
return fetchUser(userId);
}).catch((error) => {
console.error(error);
});
The controller exposes reactive loading/error/success state.
const request = onUpdate([userId] as const, async () => {
return fetchUser(userId);
});
console.log(request.loading);
console.log(request.error);
console.log(request.success);
import { usePhase } from "react-phase";
function Dashboard({ user, token, search, scrollY, price }: Props) {
const { onMount, onUnmount, onUpdate } = usePhase();
onMount(() => {
console.log("Dashboard mounted");
});
onUnmount(() => {
console.log("Dashboard cleanup");
});
onUpdate([user, token] as const, () => {
console.log("Both changed");
}).and();
onUpdate([search] as const, async ({ signal }) => {
const response = await fetch(`/api/search?q=${search}`, { signal });
return response.json();
})
.debounce(400)
.retry(2)
.catch(console.error);
onUpdate([scrollY] as const, () => {
console.log("Tracking scroll...");
}).throttle(100);
onUpdate([price] as const, () => {
console.log("Threshold crossed");
}).when((prev, current) => {
return prev[0] < 1000 && current[0] >= 1000;
});
return <div>Dashboard</div>;
}
import { useMount } from "react-phase";
function App() {
const onMount = useMount();
onMount(() => {
console.log("mounted");
});
return <div>Hello</div>;
}
import { useUnmount } from "react-phase";
function App() {
const onUnmount = useUnmount();
onUnmount(() => {
console.log("cleanup");
});
return <div>Hello</div>;
}
import { useUpdate } from "react-phase";
function App() {
const onUpdate = useUpdate();
onUpdate([count] as const, () => {
console.log("updated");
});
return <div>Hello</div>;
}
onUpdate() returns a chainable phase controller.
| Method | Description |
|---|---|
.and() |
Run when ALL dependencies change |
.or() |
Run when ANY dependency changes |
.when(fn) |
Custom dependency matcher |
.debounce(ms) |
Delay execution |
.throttle(ms) |
Limit execution frequency |
.retry(count) |
Retry failed async phases |
.catch(handler) |
Handle async errors |
const { onMount, onUnmount, onUpdate } = usePhase();
Runs once after mount. Return a function for cleanup.
Runs once before unmount.
Returns a chainable PhaseController<T>.
interface PhaseController<T extends Deps> {
loading: boolean;
error: unknown | null;
success: boolean;
and(): this;
or(): this;
when(fn: MatcherFn<T>): this;
debounce(ms: number): this;
throttle(ms: number): this;
retry(count: number): this;
catch(handler: (error: unknown) => void): this;
}
react-phase is designed to:
- embrace React
- reduce
useEffectcomplexity - improve readability
- provide smarter dependency handling
- simplify async effects
- remain lightweight and predictable
This library does not replace React internally. It provides a cleaner lifecycle abstraction layer on top of React's lifecycle system.
- mount phase
- unmount phase
- update phase
- AND dependency execution
- OR dependency execution
- custom dependency matchers via
.when() - debounce support
- throttle support
- async orchestration
- retry handling with exponential backoff
- request cancellation via AbortSignal
- full TypeScript support
- lifecycle devtools
- transition scheduling
- update batching
- execution tracing
- previous dependency snapshots
- custom schedulers
- middleware system
- plugin ecosystem
MIT