React 35 ⚛️ Custom Hooks Basics
A custom hook is a function whose name starts with use and that calls other hooks. That is the entire definition. There is no special syntax, no registration, no framework API. A custom hook is a function that extracts component logic into a reusable unit. The logic inside it is the same hooks—useState, useEffect, useRef, useContext—that a component uses directly. The extraction is the mechanism that keeps components readable and the logic testable.
Key point: A custom hook is a function that starts with use and calls at least one hook. It can call other custom hooks. It returns whatever the caller needs: a value, an array, an object, a function, or a combination. The rules of hooks apply to custom hooks exactly as they apply to built-in hooks: call them at the top level of a component or another hook, and never inside loops, conditions, or nested functions.
Why custom hooks exist
The duplication problem. Two components that fetch data from different endpoints write the same loading, error, and data logic twice. The logic is identical except for the URL. A custom hook extracts the logic into one place, parameterized by the URL, and both components use it.
The readability problem. A component that manages five pieces of state, three effects, and two refs is hard to read. The component’s purpose—what it renders—is buried in the mechanics of how it gets the data. A custom hook moves the mechanics out, and the component becomes a short function that calls the hook and renders the result.
The testing problem. A custom hook can be tested independently of the component that uses it. The test calls the hook, exercises its logic, and asserts on the result. The component test focuses on the rendering. The separation makes both tests simpler.
The composition problem. A custom hook can call other custom hooks. A useUserProfile hook can call useFetch for the data and useAuth for the token. The composition is the same as components composing components: small pieces combined into larger ones.
The convention problem. The use prefix is not decoration. The React linter uses it to identify hooks and enforce the rules. A function that calls useState but is not named with the use prefix is not recognized as a hook, and the linter does not check it. A function named with the use prefix that does not call a hook is a false positive, and the linter warns. The prefix is the contract.
The sharing problem. The logic in a custom hook is shared by importing the hook, not by copying the code. A fix in the hook fixes every component that uses it. The sharing is the point.
a. Writing a custom hook
A custom hook is a function that starts with use and calls other hooks. It returns whatever the caller needs.
import { useState, useEffect } from 'react';
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount((c) => c + 1);
const decrement = () => setCount((c) => c - 1);
const reset = () => setCount(initial);
return { count, increment, decrement, reset };
}
The hook returns an object with the state and the functions. The component uses it.
function Counter() {
const { count, increment, decrement, reset } = useCounter(10);
return (
<div>
<p>{count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
);
}
The component does not know how the counter is implemented. It receives the state and the functions and uses them. The logic is in the hook, and every component that needs a counter uses the same hook.
The return value can be an array if the caller benefits from positional destructuring.
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = () => setValue((v) => !v);
return [value, toggle];
}
// Usage
const [isOpen, toggleOpen] = useToggle();
The array return mirrors the useState convention. The object return is more common for hooks with more than two values because the names are explicit.
The custom hook can accept arguments. The arguments are the parameters of the hook, and they can be any values.
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
The key and initialValue are the arguments. The hook reads from localStorage on mount and writes to it when the value changes.
The custom hook can call other custom hooks.
function useUserProfile(userId) {
const { data, loading, error } = useFetch(`/api/users/${userId}`);
const { token } = useAuth();
return { user: data, loading, error, token };
}
The useFetch and useAuth are custom hooks. The useUserProfile composes them. The component that calls useUserProfile gets the combined result.
The custom hook can return functions that close over the hook’s state.
function useDebouncedValue(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
The useDebouncedValue returns the debounced value. The component uses it in a search.
function Search() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 300);
useEffect(() => {
if (debouncedQuery) {
search(debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
The component keeps the immediate query for the input and uses the debounced value for the search. The hook encapsulates the debouncing logic.
b. The fetch hook
The fetch hook is the canonical custom hook. It extracts the loading, error, and data logic.
import { useState, useEffect } from 'react';
function useFetch(url) {
const [state, setState] = useState({
status: 'idle',
data: null,
error: null,
});
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading', data: null, error: null });
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setState({ status: 'success', data, error: null }))
.catch((err) => {
if (err.name === 'AbortError') return;
setState({ status: 'error', data: null, error: err });
});
return () => controller.abort();
}, [url]);
return state;
}
The hook returns the state object. The component renders the branches.
function UserProfile({ userId }) {
const { status, data, error } = useFetch(`/api/users/${userId}`);
if (status === 'loading') return <Spinner />;
if (status === 'error') return <ErrorMessage error={error} />;
if (status === 'success') return <p>{data.name}</p>;
return null;
}
The component is short. The fetch logic is in the hook. Every component that fetches uses the same hook, and the loading, error, and abort logic is in one place.
The hook can be extended to include a retry.
function useFetch(url, options = {}) {
const { retries = 0 } = options;
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState({
status: 'idle',
data: null,
error: null,
});
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading', data: null, error: null });
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setState({ status: 'success', data, error: null }))
.catch((err) => {
if (err.name === 'AbortError') return;
if (attempt < retries) {
setTimeout(() => setAttempt((a) => a + 1), 2 ** attempt * 1000);
} else {
setState({ status: 'error', data: null, error: err });
}
});
return () => controller.abort();
}, [url, attempt, retries]);
const retry = () => setAttempt((a) => a + 1);
return { ...state, retry };
}
The hook now accepts a retries option and returns a retry function. The retry is automatic up to the limit, and manual via the returned function.
The hook can be parameterized with the fetch options.
function useFetch(url, options) {
const [state, setState] = useState({ status: 'idle', data: null, error: null });
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading', data: null, error: null });
fetch(url, { ...options, signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setState({ status: 'success', data, error: null }))
.catch((err) => {
if (err.name === 'AbortError') return;
setState({ status: 'error', data: null, error: err });
});
return () => controller.abort();
}, [url, options]);
return state;
}
The options is in the dependencies. The caller must memoize it, or the effect re-runs on every render.
const options = useMemo(() => ({ headers: { Auth: token } }), [token]);
const state = useFetch(url, options);
The useMemo stabilizes the options. The hook is the same; the caller is responsible for the stable dependency.
c. The rules of hooks and the linter
The rules of hooks apply to custom hooks exactly as they apply to built-in hooks.
Rule 1: Call hooks at the top level. Do not call hooks inside loops, conditions, or nested functions. The order of the hooks must be the same on every render.
// BAD: conditional hook
function Component({ enabled }) {
if (enabled) {
const [value, setValue] = useState(0); // error
}
}
// GOOD: unconditional
function Component({ enabled }) {
const [value, setValue] = useState(0);
if (!enabled) return null;
}
Rule 2: Call hooks from React functions. A hook can be called from a component or from another hook. It cannot be called from a regular function.
// BAD: hook in a regular function
function calculate() {
const [value, setValue] = useState(0); // error
}
// GOOD: hook in a custom hook
function useCalculate() {
const [value, setValue] = useState(0);
return value;
}
The use prefix is what the linter uses to identify a function as a hook. A function named useCalculate is recognized as a hook, and the linter checks the rules inside it. A function named calculate is not.
The eslint-plugin-react-hooks rule enforces both rules. The rules-of-hooks rule catches conditional hooks and hooks in non-React functions. The exhaustive-deps rule catches missing dependencies in useEffect, useMemo, and useCallback. Both rules apply to custom hooks.
The linter does not check the return value or the logic of a custom hook. It only checks that the hooks are called in the right places with the right dependencies. The correctness of the logic is the developer’s responsibility.
A custom hook can return anything. The convention is to return an object for multiple values and an array for two values, but the choice is the developer’s.
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handler = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return size;
}
// Usage
const { width, height } = useWindowSize();
The hook returns an object with the window size. The component destructures it.
A custom hook can accept a ref and return a ref.
function useClickOutside(ref, handler) {
useEffect(() => {
const listener = (event) => {
if (!ref.current || ref.current.contains(event.target)) return;
handler(event);
};
document.addEventListener('mousedown', listener);
return () => document.removeEventListener('mousedown', listener);
}, [ref, handler]);
}
The hook attaches a listener to the document and calls the handler when the click is outside the ref. The hook takes the ref and the handler as arguments.
The handler is in the dependencies. If the caller passes a new function on every render, the effect re-runs on every render. The caller should memoize the handler with useCallback.
const handleClickOutside = useCallback(() => setOpen(false), []);
useClickOutside(ref, handleClickOutside);
The useCallback stabilizes the handler. The hook’s effect runs once.
Complete Example Session
// ============================================
// PART 1: BASIC CUSTOM HOOK
// ============================================
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount((c) => c + 1);
const reset = () => setCount(initial);
return { count, increment, reset };
}
// ============================================
// PART 2: USING THE HOOK
// ============================================
function Counter() {
const { count, increment, reset } = useCounter(10);
return (
<div>
<p>{count}</p>
<button onClick={increment}>+</button>
<button onClick={reset}>Reset</button>
</div>
);
}
// ============================================
// PART 3: ARRAY RETURN
// ============================================
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = () => setValue((v) => !v);
return [value, toggle];
}
const [isOpen, toggleOpen] = useToggle();
// ============================================
// PART 4: LOCAL STORAGE HOOK
// ============================================
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
// ============================================
// PART 5: DEBOUNCE HOOK
// ============================================
function useDebouncedValue(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
// ============================================
// PART 6: FETCH HOOK
// ============================================
function useFetch(url) {
const [state, setState] = useState({
status: 'idle',
data: null,
error: null,
});
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading', data: null, error: null });
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setState({ status: 'success', data, error: null }))
.catch((err) => {
if (err.name === 'AbortError') return;
setState({ status: 'error', data: null, error: err });
});
return () => controller.abort();
}, [url]);
return state;
}
// ============================================
// PART 7: WINDOW SIZE HOOK
// ============================================
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handler = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return size;
}
// ============================================
// PART 8: CLICK OUTSIDE HOOK
// ============================================
function useClickOutside(ref, handler) {
useEffect(() => {
const listener = (event) => {
if (!ref.current || ref.current.contains(event.target)) return;
handler(event);
};
document.addEventListener('mousedown', listener);
return () => document.removeEventListener('mousedown', listener);
}, [ref, handler]);
}
// ============================================
// PART 9: COMPOSING HOOKS
// ============================================
function useUserProfile(userId) {
const { data, status, error } = useFetch(`/api/users/${userId}`);
const { token } = useAuth();
return { user: data, status, error, token };
}
// ============================================
// PART 10: CONDITIONAL HOOK BUG
// ============================================
// BAD: hook inside a condition
function Component({ enabled }) {
if (enabled) {
const [value, setValue] = useState(0); // error
}
}
// GOOD: hook at the top
function Component({ enabled }) {
const [value, setValue] = useState(0);
if (!enabled) return null;
}
The ten parts covered a basic custom hook, using the hook, the array return, the local storage hook, the debounce hook, the fetch hook, the window size hook, the click outside hook, composing hooks, and the conditional hook bug.
Quick Reference
Custom Hook Rules
| Rule | Requirement |
|---|---|
| Name | Starts with use |
| Calls hooks | At least one |
| Top level | Not in conditions or loops |
| React function | Component or hook |
| Return | Anything |
Common Custom Hooks
| Hook | Purpose |
|---|---|
useCounter | Counter state |
useToggle | Boolean toggle |
useLocalStorage | Persisted state |
useDebouncedValue | Debounced value |
useFetch | Data fetching |
useWindowSize | Window dimensions |
useClickOutside | Detect outside click |
useInterval | Interval with cleanup |
usePrevious | Previous value |
useAuth | Authentication state |
Return Conventions
| Values | Convention |
|---|---|
| One | Single value |
| Two | Array [a, b] |
| Many | Object { a, b, c } |
| State + actions | Object |
The Linter
| Rule | Catches |
|---|---|
rules-of-hooks | Conditional hooks, hooks in non-React functions |
exhaustive-deps | Missing dependencies |
Best Practices
✅ Do This:
// Name the hook with the use prefix
function useCounter() { } // ✅
// Call hooks at the top level
function useCounter() {
const [count, setCount] = useState(0);
return count;
} // ✅
// Return an object for multiple values
return { count, increment, reset }; // ✅
// Return an array for two values
return [value, setValue]; // ✅
// Compose custom hooks
function useUserProfile(id) {
const { data } = useFetch(`/api/users/${id}`);
return data;
} // ✅
// Memoize handlers passed to hooks
const handler = useCallback(() => {}, []);
useClickOutside(ref, handler); // ✅
// Test the hook independently
// Use @testing-library/react-hooks or renderHook // ✅
❌ Don’t Do This:
// Don't call hooks conditionally
if (enabled) {
const [value, setValue] = useState(0); // ❌
}
// Don't call hooks in loops
for (let i = 0; i < 10; i++) {
useEffect(() => {}); // ❌
}
// Don't call hooks from regular functions
function calculate() {
const [value, setValue] = useState(0); // ❌
}
// Don't name a hook without the use prefix
function counter() {
const [count, setCount] = useState(0); // ⚠️
}
// Don't return a new object on every render from a hook
return { count, increment: () => setCount(c => c + 1) }; // ⚠️
// Don't forget to memoize the handler dependency
useClickOutside(ref, () => setOpen(false)); // ⚠️
// Don't test the hook only through the component
// Test the hook directly // ⚠️
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Conditional hook error | Hook inside a condition | Move to top level |
| Linter does not check | No use prefix | Add the prefix |
| Re-renders on every call | New function returned | useCallback |
| Handler dependency loops | Unstable handler | useCallback |
| Hook logic duplicated | No extraction | Extract to hook |
| Test hard to write | Logic in the component | Extract to hook |
| Hook order changes | Conditional hooks | Top-level only |
Real-World Examples
1. Counter
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
return { count, increment: () => setCount((c) => c + 1) };
}
2. Toggle
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
return [value, () => setValue((v) => !v)];
}
3. Local Storage
function useLocalStorage(key, initial) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initial;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
4. Debounced Value
function useDebouncedValue(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
5. Fetch
function useFetch(url) {
const [state, setState] = useState({ status: 'idle', data: null, error: null });
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading', data: null, error: null });
fetch(url, { signal: controller.signal })
.then((r) => r.json())
.then((data) => setState({ status: 'success', data, error: null }))
.catch((err) => {
if (err.name !== 'AbortError') setState({ status: 'error', data: null, error: err });
});
return () => controller.abort();
}, [url]);
return state;
}
6. Window Size
function useWindowSize() {
const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight });
useEffect(() => {
const handler = () => setSize({ width: window.innerWidth, height: window.innerHeight });
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return size;
}
7. Click Outside
function useClickOutside(ref, handler) {
useEffect(() => {
const listener = (e) => {
if (!ref.current || ref.current.contains(e.target)) return;
handler(e);
};
document.addEventListener('mousedown', listener);
return () => document.removeEventListener('mousedown', listener);
}, [ref, handler]);
}
8. Interval
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
useEffect(() => { savedCallback.current = callback; }, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
9. Previous Value
function usePrevious(value) {
const ref = useRef();
useEffect(() => { ref.current = value; }, [value]);
return ref.current;
}
10. Online Status
function useOnlineStatus() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
return () => {
window.removeEventListener('online', on);
window.removeEventListener('offline', off);
};
}, []);
return online;
}
Visual
Custom Hook Extraction
┌─────────────────────────────────────────────────────────────┐
│ BEFORE │
│ │
│ function UserProfile({ id }) { │
│ const [data, setData] = useState(null); │
│ const [loading, setLoading] = useState(true); │
│ const [error, setError] = useState(null); │
│ │
│ useEffect(() => { │
│ const controller = new AbortController(); │
│ setLoading(true); │
│ fetch(`/api/users/${id}`, { signal: controller.signal })│
│ .then((r) => r.json()) │
│ .then((d) => { setData(d); setLoading(false); }) │
│ .catch((e) => { setError(e); setLoading(false); }); │
│ return () => controller.abort(); │
│ }, [id]); │
│ │
│ if (loading) return <Spinner />; │
│ if (error) return <Error />; │
│ return <p>{data.name}</p>; │
│ } │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ AFTER │
│ │
│ function useFetch(url) { │
│ // the same logic, extracted │
│ return { data, loading, error }; │
│ } │
│ │
│ function UserProfile({ id }) { │
│ const { data, loading, error } = useFetch(`/api/users/${id}`);│
│ if (loading) return <Spinner />; │
│ if (error) return <Error />; │
│ return <p>{data.name}</p>; │
│ } │
│ │
│ The component is short. The logic is reusable. │
│ │
└─────────────────────────────────────────────────────────────┘
Rules of Hooks
┌─────────────────────────────────────────────────────────────┐
│ GOOD: TOP LEVEL │
│ │
│ function useCounter() { │
│ const [count, setCount] = useState(0); ← top level │
│ useEffect(() => {}, []); ← top level │
│ return count; │
│ } │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ BAD: CONDITIONAL │
│ │
│ function useCounter({ enabled }) { │
│ if (enabled) { │
│ const [count, setCount] = useState(0); ← conditional │
│ } │
│ } │
│ │
│ The hook order changes between renders. │
│ React cannot match the state to the hook. │
│ │
└─────────────────────────────────────────────────────────────┘
Composition
┌─────────────────────────────────────────────────────────────┐
│ useAuth useFetch │
│ │ │ │
│ └────────┬────────┘ │
│ ▼ │
│ useUserProfile │
│ │ │
│ ▼ │
│ function UserProfile() { │
│ const { user, token } = useUserProfile(id); │
│ return <p>{user.name}</p>; │
│ } │
│ │
│ Hooks compose like components. │
│ │
└─────────────────────────────────────────────────────────────┘
Return Values
┌─────────────────────────────────────────────────────────────┐
│ ONE VALUE │
│ return count; │
│ │
│ TWO VALUES │
│ return [value, setValue]; │
│ const [value, setValue] = useToggle(); │
│ │
│ MANY VALUES │
│ return { count, increment, decrement, reset }; │
│ const { count, increment } = useCounter(); │
│ │
│ STATE + ACTION │
│ return { ...state, retry }; │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Definition | Function starting with use calling hooks |
| Prefix | use is required |
| Placement | Top level of component or hook |
| Return | Anything |
| Composition | Hooks can call hooks |
| Linter | rules-of-hooks, exhaustive-deps |
| Common hooks | useFetch, useLocalStorage, useDebouncedValue |
| Array return | [value, setter] |
| Object return | { value, actions } |
| Testing | renderHook from testing library |
Key takeaways:
- A custom hook is a function that starts with
useand calls at least one hook. There is no special syntax. The prefix is the contract, and the linter uses it to enforce the rules of hooks. - The rules of hooks apply to custom hooks. Call hooks at the top level, never inside conditions, loops, or nested functions. Call hooks from components or other hooks, never from regular functions.
- The return value is whatever the caller needs. An array for two values, an object for more. The convention mirrors
useStatefor two values and uses an object for many. - Custom hooks compose. A hook can call other custom hooks. The composition is the same as components composing components. Small hooks combine into larger ones.
- The fetch hook is the canonical example. It extracts the loading, error, data, and abort logic into one reusable function. Every component that fetches uses the same hook, and the logic lives in one place.
- The linter enforces the rules. The
rules-of-hooksrule catches conditional hooks and hooks in non-React functions. Theexhaustive-depsrule catches missing dependencies. Both apply to custom hooks. - Custom hooks are testable. The hook can be tested independently of the component. The test calls the hook and asserts on the result. The component test focuses on rendering.
Remember: Custom hooks are the mechanism for extracting and sharing component logic. They are not a new API; they are a naming convention that the linter recognizes. The logic inside a custom hook is the same useState, useEffect, and useRef that a component uses directly. The extraction makes the component readable and the logic reusable. Write a hook when the same logic appears in two components, or when a component’s logic is too complex to read. Name it with use, follow the rules, and the linter will keep you honest.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!