Skip to content
Closed
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
21 changes: 21 additions & 0 deletions .changeset/COMPT-30-state-storage-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@ciscode/reactts-developerkit": minor
---

feat(COMPT-30): add state & storage hooks — useDebounce, useLocalStorage, useSessionStorage

First batch of production-ready hooks for HooksKit (epic COMPT-2).

**New hooks:**

- `useDebounce<T>(value, delay)` — returns debounced value; resets timer on value or delay change
- `useLocalStorage<T>(key, initial)` — syncs with `localStorage`, SSR-safe, JSON serialization, parse-error fallback
- `useSessionStorage<T>(key, initial)` — same pattern for `sessionStorage`

**Implementation details:**

- Shared `storage.ts` helper (`readStorageValue` / `writeStorageValue`) encapsulates SSR guard (`typeof window === 'undefined'`) and JSON parse fallback
- Generics inferred at call site — no manual type params required
- Zero runtime dependencies
- `tsc --noEmit` passes, ESLint passes (0 warnings), 13/13 tests pass, coverage ≥ 91%
- All three hooks exported from `src/index.ts`
88 changes: 45 additions & 43 deletions .github/instructions/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -1,58 +1,61 @@
# Copilot Instructions - React Component Library
# Copilot Instructions - HooksKit

> **Purpose**: Development guidelines for React component libraries - reusable, well-structured components for modern apps.
> **Purpose**: Development guidelines for HooksKit — production-ready React hooks with zero runtime deps.

---

## 🎯 Module Overview

**Package**: `@ciscode/ui-components` (example)
**Type**: React Component Library
**Package**: `@ciscode/reactts-developerkit`
**Epic**: COMPT-2 — HooksKit
**Type**: React Hooks Library
**Framework**: React 18+, TypeScript 5+
**Build**: Vite/tsup
**Build**: tsup
**Testing**: Vitest + React Testing Library
**Distribution**: NPM package
**Purpose**: Reusable, production-ready React components for building modern UIs
**Purpose**: 12 production-ready React hooks. Zero runtime deps. SSR-safe.

### Typical Module Responsibilities:
### Hook Groups:

- Atomic UI components (Button, Input, Card, etc.)
- Composite components (Form, Modal, Navigation, etc.)
- Hooks for common patterns
- Type definitions and props interfaces
- Accessibility compliance (WCAG 2.1 AA)
- Theming and customization
- Comprehensive documentation
- **State & Storage** — `useDebounce`, `useLocalStorage`, `useSessionStorage`
- **DOM & Events** — _(upcoming)_
- **Async & Lifecycle** — _(upcoming)_

### Module Responsibilities:

- Generic, fully-typed hooks with inference at call site
- SSR-safe (all `window`/`document` access guarded with `typeof window === 'undefined'`)
- JSON serialization for storage hooks (parse-error fallback to initial value)
- Zero runtime dependencies
- WCAG-accessible patterns where applicable
- Comprehensive tests (hooks ≥ 90% coverage)

---

## 🏗️ Module Structure

```
src/
├── components/ # React components
│ ├── Button/
│ │ ├── Button.tsx # Component
│ │ ├── Button.test.tsx # Tests
│ │ ├── Button.types.ts # Props types
│ │ └── index.ts # Exports
│ ├── Input/
│ ├── Modal/
│ └── Form/
├── hooks/ # Custom hooks
│ ├── useModal.ts
│ ├── useForm.ts
│ └── useModal.test.ts
├── context/ # Context providers
│ ├── ThemeContext.tsx
│ └── FormContext.tsx
├── types/ # TypeScript types
│ └── common.types.ts
├── utils/ # Utilities
│ └── classNameUtils.ts
└── index.ts # Public API
├── components/ # Minimal supporting components
│ ├── NoopButton.tsx
│ └── index.ts
├── hooks/ # All public hooks
│ ├── storage.ts # Internal SSR-safe storage helpers
│ ├── useDebounce.ts # COMPT-30 ✅
│ ├── useDebounce.test.ts
│ ├── useLocalStorage.ts # COMPT-30 ✅
│ ├── useLocalStorage.test.ts
│ ├── useSessionStorage.ts # COMPT-30 ✅
│ ├── useSessionStorage.test.ts
│ └── index.ts # Hook barrel
├── utils/ # Framework-agnostic utils
│ ├── noop.ts
│ └── index.ts
└── index.ts # Public API (only entry point)
```

> ⚠️ Only export from `src/index.ts`. Deep imports are forbidden.

---

## 📝 Naming Conventions
Expand Down Expand Up @@ -277,18 +280,17 @@ export type { ButtonProps, ModalProps, InputProps, FormProps } from './component
**1. Branch Creation:**

```bash
feature/UI-MODULE-123-add-datepicker
bugfix/UI-MODULE-456-fix-modal-focus
refactor/UI-MODULE-789-extract-button-styles
feat/COMPT-30-state-storage-hooks
bugfix/COMPT-XX-short-description
```

**2. Task Documentation:**
> Branch names must reference the Jira ticket (COMPT-XX format). Pull from `develop` before opening PR.

Create task file:
**PR targets:**

```
docs/tasks/active/UI-MODULE-123-add-datepicker.md
```
- Feature branches → `develop`
- `develop` → `master` on Friday release only
- Never open a PR directly to `master`

**Task structure:**

Expand Down
3 changes: 0 additions & 3 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged
3 changes: 3 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
// Example placeholder export — replace with real hooks later.
export const __hooks_placeholder = true;

export * from './useDebounce';
export * from './useLocalStorage';
export * from './useSessionStorage';
export * from './useNoop';
29 changes: 29 additions & 0 deletions src/hooks/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export function readStorageValue<T>(storage: Storage | undefined, key: string, initialValue: T): T {
if (typeof window === 'undefined' || storage === undefined) {

Check warning on line 2 in src/hooks/storage.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis.window` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-x02rRXya9pO3QZmz&open=AZ0-x02rRXya9pO3QZmz&pullRequest=6
return initialValue;
}

try {
const item = storage.getItem(key);

if (item === null) {
return initialValue;
}

return JSON.parse(item) as T;
} catch {
return initialValue;
}
}

export function writeStorageValue<T>(storage: Storage | undefined, key: string, value: T): void {
if (typeof window === 'undefined' || storage === undefined) {

Check warning on line 20 in src/hooks/storage.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis.window` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-x02rRXya9pO3QZm0&open=AZ0-x02rRXya9pO3QZm0&pullRequest=6
return;
}

try {
storage.setItem(key, JSON.stringify(value));
} catch {
// Swallow write errors (quota/security) while keeping hook state usable.
}
}
78 changes: 78 additions & 0 deletions src/hooks/useDebounce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
afterEach(() => {
vi.useRealTimers();
});

it('returns the initial value immediately and updates after delay', () => {
vi.useFakeTimers();

const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
initialProps: { value: 'first', delay: 100 },
});

expect(result.current).toBe('first');

rerender({ value: 'second', delay: 100 });
expect(result.current).toBe('first');

act(() => {
vi.advanceTimersByTime(100);
});

expect(result.current).toBe('second');
});

it('resets the timer when value changes', () => {
vi.useFakeTimers();

const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
initialProps: { value: 'a', delay: 100 },
});

rerender({ value: 'b', delay: 100 });

act(() => {
vi.advanceTimersByTime(50);
});

rerender({ value: 'c', delay: 100 });

act(() => {
vi.advanceTimersByTime(50);
});

expect(result.current).toBe('a');

act(() => {
vi.advanceTimersByTime(50);
});

expect(result.current).toBe('c');
});

it('resets the timer when delay changes', () => {
vi.useFakeTimers();

const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
initialProps: { value: 1, delay: 100 },
});

rerender({ value: 2, delay: 200 });

act(() => {
vi.advanceTimersByTime(100);
});

expect(result.current).toBe(1);

act(() => {
vi.advanceTimersByTime(100);
});

expect(result.current).toBe(2);
});
});
17 changes: 17 additions & 0 deletions src/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';

export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);

useEffect(() => {
const timeoutId = window.setTimeout(() => {

Check warning on line 7 in src/hooks/useDebounce.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-x03yRXya9pO3QZm-&open=AZ0-x03yRXya9pO3QZm-&pullRequest=6
setDebouncedValue(value);
}, delay);

return () => {
window.clearTimeout(timeoutId);

Check warning on line 12 in src/hooks/useDebounce.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-x03yRXya9pO3QZm_&open=AZ0-x03yRXya9pO3QZm_&pullRequest=6
Comment on lines +7 to +12
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useDebounce directly references window.setTimeout/clearTimeout. The repo guidelines state hooks must guard all window/document access (typeof window === 'undefined'). Consider using the global setTimeout/clearTimeout (or globalThis) and/or short-circuiting when window is unavailable to keep the hook usable outside the browser (SSR/tests/non-DOM runtimes).

Suggested change
const timeoutId = window.setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
window.clearTimeout(timeoutId);
const timeoutId = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timeoutId);

Copilot uses AI. Check for mistakes.
};
}, [value, delay]);

return debouncedValue;
}
48 changes: 48 additions & 0 deletions src/hooks/useLocalStorage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
import { readStorageValue } from './storage';
import { useLocalStorage } from './useLocalStorage';

describe('useLocalStorage', () => {
afterEach(() => {
if (typeof window !== 'undefined') {

Check warning on line 8 in src/hooks/useLocalStorage.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis.window` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-x03ZRXya9pO3QZm3&open=AZ0-x03ZRXya9pO3QZm3&pullRequest=6
window.localStorage.clear();

Check warning on line 9 in src/hooks/useLocalStorage.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-x03ZRXya9pO3QZm4&open=AZ0-x03ZRXya9pO3QZm4&pullRequest=6
}
vi.unstubAllGlobals();
});

it('reads existing JSON value from localStorage', () => {
window.localStorage.setItem('user', JSON.stringify({ name: 'Ana' }));

Check warning on line 15 in src/hooks/useLocalStorage.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-x03ZRXya9pO3QZm5&open=AZ0-x03ZRXya9pO3QZm5&pullRequest=6

const { result } = renderHook(() => useLocalStorage('user', { name: 'Default' }));

expect(result.current[0]).toEqual({ name: 'Ana' });
});

it('syncs updates to localStorage with JSON serialization', () => {
const { result } = renderHook(() => useLocalStorage('count', 0));
expectTypeOf(result.current[0]).toEqualTypeOf<number>();

act(() => {
result.current[1](5);
});

expect(window.localStorage.getItem('count')).toBe('5');

Check warning on line 30 in src/hooks/useLocalStorage.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-x03ZRXya9pO3QZm6&open=AZ0-x03ZRXya9pO3QZm6&pullRequest=6
expect(result.current[0]).toBe(5);
});

it('returns initial value on JSON parse error', () => {
window.localStorage.setItem('bad-json', '{ invalid json');

Check warning on line 35 in src/hooks/useLocalStorage.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-x03ZRXya9pO3QZm7&open=AZ0-x03ZRXya9pO3QZm7&pullRequest=6

const { result } = renderHook(() => useLocalStorage('bad-json', 42));

expect(result.current[0]).toBe(42);
});

it('returns initial value when window is undefined (SSR guard)', () => {
vi.stubGlobal('window', undefined);

const value = readStorageValue(undefined, 'ssr', 'fallback');
expect(value).toBe('fallback');
});
Comment on lines +42 to +47
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test name suggests it's validating the useLocalStorage hook, but the assertion is actually against readStorageValue. Either rename the test to reflect what it's covering, or render the hook with window unavailable to validate the hook-level SSR behavior.

Copilot uses AI. Check for mistakes.
});
17 changes: 17 additions & 0 deletions src/hooks/useLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { readStorageValue, writeStorageValue } from './storage';

export function useLocalStorage<T>(key: string, initialValue: T): [T, Dispatch<SetStateAction<T>>] {
const storage = typeof window === 'undefined' ? undefined : window.localStorage;

Check warning on line 6 in src/hooks/useLocalStorage.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis.window` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-x03lRXya9pO3QZm8&open=AZ0-x03lRXya9pO3QZm8&pullRequest=6

Check warning on line 6 in src/hooks/useLocalStorage.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-x03lRXya9pO3QZm9&open=AZ0-x03lRXya9pO3QZm9&pullRequest=6

const [storedValue, setStoredValue] = useState<T>(() => {
return readStorageValue(storage, key, initialValue);
});

useEffect(() => {
writeStorageValue(storage, key, storedValue);
}, [key, storedValue, storage]);

return [storedValue, setStoredValue];
}
Comment on lines +5 to +17
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The state initializer reads from storage only on the first render. If key changes between renders, the hook will not re-read the new key; instead the effect will write the previous storedValue into the new key. Either document/assume key is stable (and remove key from the write effect deps), or add logic to re-sync storedValue from storage when key changes.

Copilot uses AI. Check for mistakes.
Loading
Loading