Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 192 additions & 61 deletions README.md

Large diffs are not rendered by default.

340 changes: 124 additions & 216 deletions package-lock.json

Large diffs are not rendered by default.

15 changes: 7 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
{
"name": "@okyrychenko-dev/react-effect-when",
"version": "1.2.0",
"description": "Declarative conditional effects for React with less Strict Mode noise in development",
"description": "Run effects only when your deps are ready — with the narrowed types to prove it",
"keywords": [
"react",
"hook",
"useeffect",
"strict-mode",
"strictmode",
"double-invoke",
"double-render",
"type-safe",
"narrowing",
"type-narrowing",
"guard-predicate",
"effect",
"conditional-effect",
"useeffect-strict-mode",
"useeffect-guard",
"predicate",
"typescript",
"react-hooks",
"development"
"strict-mode"
],
"homepage": "https://github.com/okyrychenko-dev/react-effect-when#readme",
"bugs": {
Expand Down Expand Up @@ -91,7 +90,7 @@
"tsup": "^8.5.1",
"typescript": "^5.3.3",
"typescript-eslint": "^8.46.4",
"vitest": "^4.0.9"
"vitest": "^4.1.10"
},
"allowScripts": {
"esbuild@0.27.4": true
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { useEffectWhen, predicates } from "./useEffectWhen";
export { useEffectWhenReady } from "./useEffectWhenReady";
export { useEffectWhenTruthy } from "./useEffectWhenTruthy";
export { useEffectWhenChanged } from "./useEffectWhenChanged";
export { useEffectWhenMatch } from "./useEffectWhenMatch";
export { createEffectWhen } from "./createEffectWhen";
export type {
Falsy,
Expand All @@ -13,3 +14,4 @@ export type {
UseEffectWhenOptions,
UseEffectWhenPredicates,
} from "./useEffectWhen";
export type { Discriminant, MatchedDeps } from "./useEffectWhenMatch";
139 changes: 139 additions & 0 deletions src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { renderHook } from "@testing-library/react";
import { type PropsWithChildren, StrictMode, createElement } from "react";
import { describe, expect, expectTypeOf, it, vi } from "vitest";
import { useEffectWhenMatch } from "../useEffectWhenMatch";

interface QueryData {
id: string;
}

interface QueryPending {
status: "pending";
}

interface QueryError {
status: "error";
error: Error;
}

interface QuerySuccess {
status: "success";
data: QueryData;
}

type QueryResult = QueryPending | QueryError | QuerySuccess;

interface JobQueued {
kind: "queued";
}

interface JobDone {
kind: "done";
output: string;
}

type Job = JobQueued | JobDone;

function queryResult(result: QueryResult): QueryResult {
return result;
}

function jobResult(result: Job): Job {
return result;
}

describe("useEffectWhenMatch", () => {
it("should run and narrow deps when the field matches", () => {
const track = vi.fn<(id: string) => void>();
const query = queryResult({ status: "success", data: { id: "item-1" } });

renderHook(() =>
useEffectWhenMatch(
([result]) => {
expectTypeOf(result.data).toEqualTypeOf<QueryData>();

track(result.data.id);
},
[query],
"status",
"success"
)
);

expect(track).toHaveBeenCalledWith("item-1");
});

it("should not run when the field does not match", () => {
const track = vi.fn<(id: string) => void>();
const query = queryResult({ status: "pending" });

renderHook(() =>
useEffectWhenMatch(([result]) => track(result.data.id), [query], "status", "success")
);

expect(track).not.toHaveBeenCalled();
});

it("should re-run when the field changes to a match with once: false", () => {
const track = vi.fn<(id: string) => void>();

const { rerender } = renderHook(
({ query }: { query: QueryResult }) =>
useEffectWhenMatch(([result]) => track(result.data.id), [query], "status", "success", {
once: false,
}),
{ initialProps: { query: { status: "pending" } } }
);

expect(track).not.toHaveBeenCalled();

rerender({ query: { status: "success", data: { id: "item-2" } } });
expect(track).toHaveBeenCalledWith("item-2");

rerender({ query: { status: "success", data: { id: "item-3" } } });
expect(track).toHaveBeenCalledWith("item-3");
expect(track).toHaveBeenCalledTimes(2);
});

it("should work with any discriminant field name, not just `status`", () => {
const track = vi.fn<(output: string) => void>();
const job = jobResult({ kind: "done", output: "result-1" });

renderHook(() =>
useEffectWhenMatch(
([result]) => {
expectTypeOf(result.output).toEqualTypeOf<string>();

track(result.output);
},
[job],
"kind",
"done"
)
);

expect(track).toHaveBeenCalledWith("result-1");
});

describe("Strict Mode behavior", () => {
it("should not re-run on rerender inside Strict Mode when once is true", () => {
const track = vi.fn<(id: string) => void>();
const wrapper = ({ children }: PropsWithChildren) =>
createElement(StrictMode, null, children);

const { rerender } = renderHook(
({ query }: { query: QueryResult }) =>
useEffectWhenMatch(([result]) => track(result.data.id), [query], "status", "success"),
{
initialProps: { query: { status: "success", data: { id: "item-1" } } },
wrapper,
}
);

expect(track).toHaveBeenCalledTimes(1);

rerender({ query: { status: "success", data: { id: "item-2" } } });
expect(track).toHaveBeenCalledTimes(1);
});
});
});
2 changes: 2 additions & 0 deletions src/useEffectWhenMatch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { useEffectWhenMatch } from "./useEffectWhenMatch";
export type { Discriminant, MatchedDeps } from "./useEffectWhenMatch.types";
26 changes: 26 additions & 0 deletions src/useEffectWhenMatch/useEffectWhenMatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useEffectWhen } from "../useEffectWhen";
import { isMatch } from "./useEffectWhenMatch.utils";
import type { UseEffectWhenEffect, UseEffectWhenOptions } from "../useEffectWhen";
import type { Discriminant, MatchedDeps } from "./useEffectWhenMatch.types";

/**
* Gates an effect on a discriminated union at `deps[0]` whose field at `key`
* equals `value`, narrowing the effect's dependency to that matched variant
* (e.g. a query result narrowed to its "success" shape).
*
* This specialized helper accepts one dependency. Use `useEffectWhen` with a
* custom type guard for conditions involving multiple dependencies.
*/
export function useEffectWhenMatch<
K extends PropertyKey,
Q extends Discriminant<K>,
V extends Q[K],
>(
effect: UseEffectWhenEffect<MatchedDeps<K, Q, V>>,
deps: readonly [Q],
key: K,
value: V,
options?: UseEffectWhenOptions<readonly [Q]>
): void {
useEffectWhen(effect, deps, isMatch<K, Q, V>(key, value), options);
}
12 changes: 12 additions & 0 deletions src/useEffectWhenMatch/useEffectWhenMatch.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** An object discriminated by a literal-valued field at key `K`. */
export type Discriminant<K extends PropertyKey> = Record<K, PropertyKey>;

/**
* Narrows a single dependency `Q` (discriminated by `K`) down to the variant
* whose `K` field equals `V`.
*/
export type MatchedDeps<
K extends PropertyKey,
Q extends Discriminant<K>,
V extends Q[K],
> = readonly [Extract<Q, Record<K, V>>];
11 changes: 11 additions & 0 deletions src/useEffectWhenMatch/useEffectWhenMatch.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { GuardPredicate } from "../useEffectWhen";
import type { Discriminant, MatchedDeps } from "./useEffectWhenMatch.types";

export function isMatch<K extends PropertyKey, Q extends Discriminant<K>, V extends Q[K]>(
key: K,
value: V
): GuardPredicate<readonly [Q], MatchedDeps<K, Q, V>> {
return function matchesDiscriminant(deps): deps is MatchedDeps<K, Q, V> {
return deps[0][key] === value;
};
}
38 changes: 38 additions & 0 deletions tsup.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
import { readFile, writeFile } from "node:fs/promises";
import { defineConfig } from "tsup";

const USE_CLIENT_DIRECTIVE = '"use client";\n';
const OUTPUT_FILES = ["dist/index.js", "dist/index.cjs"];

function prependSourceMapLine(sourceMap: unknown, file: string): unknown {
if (
typeof sourceMap !== "object" ||
sourceMap === null ||
!("mappings" in sourceMap) ||
typeof sourceMap.mappings !== "string"
) {
throw new TypeError(`Invalid source map emitted for ${file}`);
}

sourceMap.mappings = `;${sourceMap.mappings}`;

return sourceMap;
}

async function prependUseClientDirective(file: string): Promise<void> {
const contents = await readFile(file, "utf8");

if (contents.startsWith(USE_CLIENT_DIRECTIVE)) {
return;
}

await writeFile(file, USE_CLIENT_DIRECTIVE + contents);

const sourceMapFile = `${file}.map`;
const sourceMap: unknown = JSON.parse(await readFile(sourceMapFile, "utf8"));
const shiftedSourceMap = prependSourceMapLine(sourceMap, sourceMapFile);

await writeFile(sourceMapFile, `${JSON.stringify(shiftedSourceMap)}\n`);
}

export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
Expand All @@ -10,4 +45,7 @@ export default defineConfig({
external: ["react"],
treeshake: true,
minify: false,
async onSuccess() {
await Promise.all(OUTPUT_FILES.map(prependUseClientDirective));
},
});
Loading