npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

react-safe-hooks

v1.0.1

Published

Runtime-safe React hooks that warn developers about common hook misuse, stale closures, incorrect dependencies, and unsafe async state updates — without affecting production builds.

Downloads

158

Readme

react-safe-hooks

Runtime-safe React hooks that warn developers about common hook misuse, stale closures, incorrect dependencies, and unsafe async state updates — without affecting production builds.

npm version TypeScript React 18+ Zero Dependencies

Why react-safe-hooks?

ESLint's react-hooks/exhaustive-deps rule catches many issues statically, but some problems only manifest at runtime:

| Problem | ESLint | react-safe-hooks | |---------|--------|------------------| | Stale closures from missing deps | ⚠️ Sometimes | ✅ Runtime detection | | Dependency array length changes | ❌ No | ✅ Detects | | State updates after unmount | ❌ No | ✅ Warns | | Unstable reference recreations | ❌ No | ✅ Heuristic detection | | Excessive recomputations | ❌ No | ✅ Tracks | | Missing context providers | ❌ No | ✅ Warns | | SSR layout effect issues | ❌ No | ✅ Auto-fallback |

Zero production cost: All validation logic is completely stripped in production builds.

Installation

npm install react-safe-hooks
# or
yarn add react-safe-hooks
# or
pnpm add react-safe-hooks

Quick Start

Simply replace your hooks with their safe counterparts:

import {
  useSafeState,
  useSafeEffect,
  useSafeCallback,
  useSafeMemo,
  useIsMounted,
} from 'react-safe-hooks';

function UserProfile({ userId }) {
  const [user, setUser] = useSafeState(null);
  const isMounted = useIsMounted();

  useSafeEffect(() => {
    fetchUser(userId).then((data) => {
      if (isMounted()) {
        setUser(data);
      }
    });
  }, [userId, isMounted]);

  const handleSave = useSafeCallback(() => {
    saveUser(user);
  }, [user]);

  const fullName = useSafeMemo(
    () => `${user?.firstName} ${user?.lastName}`,
    [user?.firstName, user?.lastName]
  );

  return <div>{fullName}</div>;
}

All Available Hooks

| Hook | Replaces | Key Features | |------|----------|--------------| | useSafeState | useState | Unmount protection | | useSafeEffect | useEffect | Dependency tracking | | useSafeCallback | useCallback | Stale closure detection | | useSafeMemo | useMemo | Recompute tracking | | useSafeRef | useRef | Initialization warnings | | useSafeLayoutEffect | useLayoutEffect | SSR safe + tracking | | useSafeReducer | useReducer | Dispatch protection | | useSafeContext | useContext | Missing provider detection | | useIsMounted | - | Mounted state tracking |


Hooks API & Examples

useSafeState

const [state, setState] = useSafeState(initialState, options?: {
  name?: string; // Identifier in warnings
});

Detects:

  • ⚠️ setState called after component unmount
  • ⚠️ Async state update patterns that may cause issues

Example: Async Data Fetching

function UserCard({ userId }) {
  const [user, setUser] = useSafeState<User | null>(null);
  const [loading, setLoading] = useSafeState(true);
  const [error, setError] = useSafeState<string | null>(null);

  useSafeEffect(() => {
    let cancelled = false;
    
    setLoading(true);
    setError(null);
    
    fetchUser(userId)
      .then((data) => {
        if (!cancelled) {
          setUser(data);    // Safe! Warns if component unmounted
          setLoading(false);
        }
      })
      .catch((err) => {
        if (!cancelled) {
          setError(err.message);
          setLoading(false);
        }
      });

    return () => {
      cancelled = true;
    };
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  return <Card user={user} />;
}

useSafeEffect

useSafeEffect(
  effect: EffectCallback,
  deps?: DependencyList,
  options?: {
    name?: string;              // Identifier in warnings
    warnOnMissingDeps?: boolean; // Default: true
    warnOnChangingDeps?: boolean; // Default: true
  }
)

Detects:

  • ⚠️ Missing dependency array
  • ⚠️ Dependency array length changes between renders
  • ⚠️ Unstable dependencies (objects/arrays recreated each render)

Example: WebSocket Connection

function ChatRoom({ roomId, userId }) {
  const [messages, setMessages] = useSafeState<Message[]>([]);
  const isMounted = useIsMounted();

  useSafeEffect(() => {
    const socket = new WebSocket(`/chat/${roomId}`);
    
    socket.onmessage = (event) => {
      if (isMounted()) {
        setMessages((prev) => [...prev, JSON.parse(event.data)]);
      }
    };

    socket.onerror = (error) => {
      console.error('WebSocket error:', error);
    };

    // Cleanup: close socket when roomId changes or unmount
    return () => {
      socket.close();
    };
  }, [roomId, isMounted], { name: 'WebSocketConnection' });

  return <MessageList messages={messages} />;
}

Example: Document Title

function PageTitle({ title }) {
  useSafeEffect(() => {
    const previousTitle = document.title;
    document.title = title;
    
    return () => {
      document.title = previousTitle;
    };
  }, [title], { name: 'DocumentTitle' });

  return null;
}

useSafeCallback

const fn = useSafeCallback(
  callback: (...args: any[]) => any,
  deps: DependencyList,
  options?: {
    name?: string;               // Identifier in warnings
    warnOnStaleClosure?: boolean; // Default: true
  }
);

Detects:

  • ⚠️ Potential stale closures
  • ⚠️ Unstable dependencies
  • ⚠️ Excessive callback identity changes

Example: Debounced Search

function SearchInput({ onSearch }) {
  const [query, setQuery] = useSafeState('');

  const debouncedSearch = useSafeCallback(
    debounce((searchQuery: string) => {
      onSearch(searchQuery);
    }, 300),
    [onSearch],
    { name: 'debouncedSearch' }
  );

  const handleChange = useSafeCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const value = e.target.value;
      setQuery(value);
      debouncedSearch(value);
    },
    [debouncedSearch],
    { name: 'handleChange' }
  );

  return (
    <input
      type="text"
      value={query}
      onChange={handleChange}
      placeholder="Search..."
    />
  );
}

Example: Event Handler with Props

function TodoItem({ todo, onToggle, onDelete }) {
  // These callbacks are stable unless their deps change
  const handleToggle = useSafeCallback(() => {
    onToggle(todo.id);
  }, [todo.id, onToggle]);

  const handleDelete = useSafeCallback(() => {
    if (confirm('Delete this item?')) {
      onDelete(todo.id);
    }
  }, [todo.id, onDelete]);

  return (
    <li>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={handleToggle}
      />
      <span>{todo.text}</span>
      <button onClick={handleDelete}>×</button>
    </li>
  );
}

useSafeMemo

const value = useSafeMemo(
  factory: () => T,
  deps: DependencyList,
  options?: {
    name?: string;             // Identifier in warnings
    warnOnRecompute?: boolean; // Default: true
    recomputeThreshold?: number; // Default: 10
  }
);

Detects:

  • ⚠️ Excessive recomputations
  • ⚠️ Unstable dependencies
  • ⚠️ Dependency array length changes

Example: Expensive Filtering

function ProductList({ products, filters }) {
  const filteredProducts = useSafeMemo(() => {
    console.log('Filtering products...'); // See when this runs
    
    return products.filter((product) => {
      if (filters.category && product.category !== filters.category) {
        return false;
      }
      if (filters.minPrice && product.price < filters.minPrice) {
        return false;
      }
      if (filters.maxPrice && product.price > filters.maxPrice) {
        return false;
      }
      if (filters.inStock && !product.inStock) {
        return false;
      }
      return true;
    });
  }, [products, filters.category, filters.minPrice, filters.maxPrice, filters.inStock], {
    name: 'filteredProducts',
    recomputeThreshold: 5,
  });

  return (
    <ul>
      {filteredProducts.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </ul>
  );
}

Example: Derived State

function OrderSummary({ items }) {
  const subtotal = useSafeMemo(
    () => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
    [items],
    { name: 'subtotal' }
  );

  const tax = useSafeMemo(
    () => subtotal * 0.08,
    [subtotal],
    { name: 'tax' }
  );

  const total = useSafeMemo(
    () => subtotal + tax,
    [subtotal, tax],
    { name: 'total' }
  );

  return (
    <div>
      <p>Subtotal: ${subtotal.toFixed(2)}</p>
      <p>Tax: ${tax.toFixed(2)}</p>
      <p><strong>Total: ${total.toFixed(2)}</strong></p>
    </div>
  );
}

useSafeRef

const ref = useSafeRef<T>(initialValue, options?: {
  name?: string;           // Identifier in warnings
  warnOnNullInit?: boolean; // Default: false
});

Example: DOM Element Reference

function AutoFocusInput({ autoFocus }) {
  const inputRef = useSafeRef<HTMLInputElement>(null, { name: 'inputRef' });

  useSafeEffect(() => {
    if (autoFocus && inputRef.current) {
      inputRef.current.focus();
    }
  }, [autoFocus]);

  return <input ref={inputRef} type="text" />;
}

Example: Previous Value Tracking

function CounterWithHistory() {
  const [count, setCount] = useSafeState(0);
  const prevCountRef = useSafeRef(count, { name: 'prevCount' });

  useSafeEffect(() => {
    prevCountRef.current = count;
  }, [count]);

  return (
    <div>
      <p>Current: {count}</p>
      <p>Previous: {prevCountRef.current}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

useSafeLayoutEffect

useSafeLayoutEffect(
  effect: EffectCallback,
  deps?: DependencyList,
  options?: {
    name?: string;
    warnOnMissingDeps?: boolean;
    warnOnChangingDeps?: boolean;
  }
)

SSR-safe version of useLayoutEffect that falls back to useEffect on the server.

Example: Tooltip Positioning

function Tooltip({ targetRef, content }) {
  const tooltipRef = useSafeRef<HTMLDivElement>(null);
  const [position, setPosition] = useSafeState({ top: 0, left: 0 });

  useSafeLayoutEffect(() => {
    if (targetRef.current && tooltipRef.current) {
      const targetRect = targetRef.current.getBoundingClientRect();
      const tooltipRect = tooltipRef.current.getBoundingClientRect();
      
      setPosition({
        top: targetRect.top - tooltipRect.height - 8,
        left: targetRect.left + (targetRect.width - tooltipRect.width) / 2,
      });
    }
  }, [targetRef], { name: 'tooltipPosition' });

  return (
    <div
      ref={tooltipRef}
      className="tooltip"
      style={{ top: position.top, left: position.left }}
    >
      {content}
    </div>
  );
}

Example: Scroll Lock

function Modal({ isOpen, children }) {
  useSafeLayoutEffect(() => {
    if (isOpen) {
      const originalOverflow = document.body.style.overflow;
      document.body.style.overflow = 'hidden';
      
      return () => {
        document.body.style.overflow = originalOverflow;
      };
    }
  }, [isOpen], { name: 'scrollLock' });

  if (!isOpen) return null;

  return (
    <div className="modal-overlay">
      <div className="modal-content">{children}</div>
    </div>
  );
}

useSafeReducer

const [state, dispatch] = useSafeReducer(reducer, initialState, init?, options?: {
  name?: string; // Identifier in warnings
});

Detects:

  • ⚠️ Dispatch called after component unmount

Example: Todo App

type TodoAction =
  | { type: 'ADD'; text: string }
  | { type: 'TOGGLE'; id: number }
  | { type: 'DELETE'; id: number }
  | { type: 'CLEAR_COMPLETED' };

interface TodoState {
  todos: Array<{ id: number; text: string; completed: boolean }>;
  nextId: number;
}

function todoReducer(state: TodoState, action: TodoAction): TodoState {
  switch (action.type) {
    case 'ADD':
      return {
        ...state,
        todos: [...state.todos, { id: state.nextId, text: action.text, completed: false }],
        nextId: state.nextId + 1,
      };
    case 'TOGGLE':
      return {
        ...state,
        todos: state.todos.map((t) =>
          t.id === action.id ? { ...t, completed: !t.completed } : t
        ),
      };
    case 'DELETE':
      return {
        ...state,
        todos: state.todos.filter((t) => t.id !== action.id),
      };
    case 'CLEAR_COMPLETED':
      return {
        ...state,
        todos: state.todos.filter((t) => !t.completed),
      };
    default:
      return state;
  }
}

function TodoApp() {
  const [state, dispatch] = useSafeReducer(
    todoReducer,
    { todos: [], nextId: 1 },
    undefined,
    { name: 'todoReducer' }
  );
  const [input, setInput] = useSafeState('');

  const handleSubmit = useSafeCallback((e: React.FormEvent) => {
    e.preventDefault();
    if (input.trim()) {
      dispatch({ type: 'ADD', text: input.trim() });
      setInput('');
    }
  }, [input]);

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={(e) => setInput(e.target.value)} />
        <button type="submit">Add</button>
      </form>
      <ul>
        {state.todos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => dispatch({ type: 'TOGGLE', id: todo.id })}
            />
            {todo.text}
            <button onClick={() => dispatch({ type: 'DELETE', id: todo.id })}>×</button>
          </li>
        ))}
      </ul>
      <button onClick={() => dispatch({ type: 'CLEAR_COMPLETED' })}>
        Clear Completed
      </button>
    </div>
  );
}

useSafeContext

const value = useSafeContext(Context, options?: {
  name?: string;           // Identifier in warnings
  throwOnMissing?: boolean; // Default: false
});

Detects:

  • ⚠️ Context used outside its Provider
  • ⚠️ Undefined context values

Example: Theme Context

interface Theme {
  primary: string;
  secondary: string;
  background: string;
}

const ThemeContext = React.createContext<Theme | undefined>(undefined);

function ThemeProvider({ children }) {
  const theme = useSafeMemo(() => ({
    primary: '#007bff',
    secondary: '#6c757d',
    background: '#ffffff',
  }), []);

  return (
    <ThemeContext.Provider value={theme}>
      {children}
    </ThemeContext.Provider>
  );
}

function ThemedButton({ children }) {
  // Will warn in dev if used outside ThemeProvider
  const theme = useSafeContext(ThemeContext, {
    name: 'ThemeContext',
    throwOnMissing: true, // Throw error instead of just warning
  });

  return (
    <button style={{ backgroundColor: theme.primary, color: '#fff' }}>
      {children}
    </button>
  );
}

Example: Auth Context

interface AuthContextValue {
  user: User | null;
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
}

const AuthContext = React.createContext<AuthContextValue | undefined>(undefined);

function useAuth() {
  return useSafeContext(AuthContext, {
    name: 'AuthContext',
    throwOnMissing: true,
  });
}

function UserMenu() {
  const { user, logout } = useAuth();

  if (!user) {
    return <LoginButton />;
  }

  return (
    <div>
      <span>Welcome, {user.name}</span>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

useIsMounted

const isMounted = useIsMounted();
// isMounted() returns true if component is mounted

A stable function reference that returns whether the component is currently mounted. Use this to guard async operations.

Example: Abort Controller Pattern

function DataFetcher({ url }) {
  const [data, setData] = useSafeState(null);
  const [error, setError] = useSafeState(null);
  const isMounted = useIsMounted();

  useSafeEffect(() => {
    const controller = new AbortController();

    fetch(url, { signal: controller.signal })
      .then((res) => res.json())
      .then((json) => {
        if (isMounted()) {
          setData(json);
        }
      })
      .catch((err) => {
        if (err.name !== 'AbortError' && isMounted()) {
          setError(err);
        }
      });

    return () => {
      controller.abort();
    };
  }, [url, isMounted]);

  return <div>{JSON.stringify(data)}</div>;
}

Real-World Example: Complete Form Component

import {
  useSafeState,
  useSafeEffect,
  useSafeCallback,
  useSafeMemo,
  useSafeReducer,
  useIsMounted,
} from 'react-safe-hooks';

interface FormState {
  values: Record<string, string>;
  errors: Record<string, string>;
  touched: Record<string, boolean>;
  isSubmitting: boolean;
}

type FormAction =
  | { type: 'SET_FIELD'; field: string; value: string }
  | { type: 'SET_ERROR'; field: string; error: string }
  | { type: 'TOUCH_FIELD'; field: string }
  | { type: 'SET_SUBMITTING'; value: boolean }
  | { type: 'RESET' };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'SET_FIELD':
      return {
        ...state,
        values: { ...state.values, [action.field]: action.value },
        errors: { ...state.errors, [action.field]: '' },
      };
    case 'SET_ERROR':
      return {
        ...state,
        errors: { ...state.errors, [action.field]: action.error },
      };
    case 'TOUCH_FIELD':
      return {
        ...state,
        touched: { ...state.touched, [action.field]: true },
      };
    case 'SET_SUBMITTING':
      return { ...state, isSubmitting: action.value };
    case 'RESET':
      return {
        values: {},
        errors: {},
        touched: {},
        isSubmitting: false,
      };
    default:
      return state;
  }
}

function ContactForm({ onSubmit }) {
  const [state, dispatch] = useSafeReducer(
    formReducer,
    { values: {}, errors: {}, touched: {}, isSubmitting: false },
    undefined,
    { name: 'contactForm' }
  );
  const [submitError, setSubmitError] = useSafeState<string | null>(null);
  const isMounted = useIsMounted();

  const isValid = useSafeMemo(() => {
    const { email, message } = state.values;
    return email?.includes('@') && message?.length > 10;
  }, [state.values.email, state.values.message], { name: 'isValid' });

  const handleChange = useSafeCallback((field: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    dispatch({ type: 'SET_FIELD', field, value: e.target.value });
  }, []);

  const handleBlur = useSafeCallback((field: string) => () => {
    dispatch({ type: 'TOUCH_FIELD', field });
  }, []);

  const handleSubmit = useSafeCallback(async (e: React.FormEvent) => {
    e.preventDefault();
    
    if (!isValid) return;

    dispatch({ type: 'SET_SUBMITTING', value: true });
    setSubmitError(null);

    try {
      await onSubmit(state.values);
      if (isMounted()) {
        dispatch({ type: 'RESET' });
      }
    } catch (err) {
      if (isMounted()) {
        setSubmitError(err.message);
        dispatch({ type: 'SET_SUBMITTING', value: false });
      }
    }
  }, [isValid, state.values, onSubmit, isMounted]);

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        placeholder="Email"
        value={state.values.email || ''}
        onChange={handleChange('email')}
        onBlur={handleBlur('email')}
      />
      {state.touched.email && state.errors.email && (
        <span className="error">{state.errors.email}</span>
      )}

      <textarea
        placeholder="Message"
        value={state.values.message || ''}
        onChange={handleChange('message')}
        onBlur={handleBlur('message')}
      />

      {submitError && <div className="error">{submitError}</div>}

      <button type="submit" disabled={!isValid || state.isSubmitting}>
        {state.isSubmitting ? 'Sending...' : 'Send'}
      </button>
    </form>
  );
}

Warning Messages

All warnings follow a consistent format:

⚠️ react-safe-hooks warning:

Component: UserProfile
Hook: useSafeEffect

Dependency array length changed between renders.
Length changed from 2 to 3.

Fix:
Ensure your dependency array has a stable length.
Conditional dependencies should be handled inside the effect, not in the array.

Common Warnings Explained

"Effect has no dependency array"

// ❌ Bad: Runs on every render
useSafeEffect(() => {
  console.log('This runs too often');
});

// ✅ Good: Explicit about when to run
useSafeEffect(() => {
  console.log('Runs once');
}, []);

"Dependency array length changed"

// ❌ Bad: Conditional dependency
useSafeEffect(() => {
  // ...
}, showExtra ? [a, b, c] : [a, b]);

// ✅ Good: Handle condition inside effect
useSafeEffect(() => {
  if (showExtra) {
    // use c
  }
}, [a, b, c, showExtra]);

"Potentially unstable dependencies"

// ❌ Bad: New object on every render
useSafeEffect(() => {
  fetch('/api', options);
}, [{ method: 'POST' }]); // ← recreated each render!

// ✅ Good: Memoize or extract
const options = useMemo(() => ({ method: 'POST' }), []);
useSafeEffect(() => {
  fetch('/api', options);
}, [options]);

"Attempted to update state after unmount"

// ❌ Bad: No unmount check
useEffect(() => {
  fetchData().then(setData); // May run after unmount!
}, []);

// ✅ Good: Check mounted state
const isMounted = useIsMounted();
useSafeEffect(() => {
  fetchData().then((data) => {
    if (isMounted()) setData(data);
  });
}, [isMounted]);

"Context value is undefined"

// ❌ Bad: Using context outside provider
function MyComponent() {
  const theme = useSafeContext(ThemeContext); // ⚠️ Warning!
  return <div style={{ color: theme?.primary }}>Hello</div>;
}

// ✅ Good: Wrap with provider
function App() {
  return (
    <ThemeProvider>
      <MyComponent />
    </ThemeProvider>
  );
}

Production Safety

This library is designed to have zero runtime cost in production:

// In development:
// - All checks run
// - Warnings are displayed
// - Full debugging context

// In production (NODE_ENV === "production"):
// - All checks are stripped
// - Hooks behave identically to native hooks
// - No performance overhead

The __DEV__ guard allows bundlers to tree-shake all development code:

// This entire block is removed in production
if (__DEV__) {
  // validation logic
}

Comparison with Native Hooks

| Feature | Native | react-safe-hooks | |---------|--------|------------------| | Functionality | ✅ | ✅ Identical | | Type safety | ✅ | ✅ Same types | | Dev warnings | ❌ | ✅ Comprehensive | | Prod performance | ✅ Optimal | ✅ Identical | | Bundle size impact | - | ~0 in prod |


FAQ

Does this affect production performance?

No. All development checks use process.env.NODE_ENV !== "production" guards, which bundlers like webpack, Rollup, and esbuild eliminate entirely during production builds.

Is this SSR compatible?

Yes. The hooks work identically in SSR environments. useSafeLayoutEffect automatically falls back to useEffect on the server to avoid warnings.

Should I use these everywhere or just for debugging?

You can safely use these as drop-in replacements for the native hooks. In production, they're identical to the native versions. In development, you get extra safety checks.

How does stale closure detection work?

The library uses heuristics to detect when dependencies change but the callback reference doesn't update. This isn't foolproof but catches common patterns.

Can I disable specific warnings?

Yes, use the options parameter:

useSafeEffect(
  () => { /* ... */ },
  [],
  { warnOnMissingDeps: false }
);

Does this replace the ESLint rules?

No, use both! ESLint catches issues statically at build time, while react-safe-hooks catches runtime patterns that static analysis can't detect.


Requirements

  • React 18.0.0 or higher
  • TypeScript 4.7+ (optional but recommended)

License

MIT © 2024