Storage and API
Storage with expiry (TTL) and a light API client that reports errors in Uzbek.
Two small things every project ends up needing: a safe wrapper around localStorage and an API
client on top of fetch. Both have no dependencies and work during SSR.
import { createStorage, createApiClient, isApiError } from '@akhatmirzo/core';Storage
Kalit: docs-demo:ism
Natija: —
'use client';
import { createStorage } from '@akhatmirzo/core';
import { Button, Field, Input } from '@akhatmirzo/ui';
import { useState } from 'react';
const store = createStorage({ prefix: 'docs-demo:' });
export default function CoreStorage() {
const [value, setValue] = useState('Sarvar');
const [result, setResult] = useState('—');
const save = (ttl?: number) => {
const ok = store.set('ism', value, { ttl });
setResult(
ok ? `Saqlandi${ttl ? ` (${ttl / 1000} soniya)` : ' (muddatsiz)'}` : "Saqlab bo'lmadi",
);
};
const read = () => {
const saved = store.get<string>('ism');
setResult(saved === null ? "null — yo'q yoki muddati o'tgan" : JSON.stringify(saved));
};
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<Field label="Qiymat" description="Kalit: docs-demo:ism">
<Input value={value} onChange={(e) => setValue(e.target.value)} />
</Field>
<div className="flex flex-wrap gap-2">
<Button variant="primary" onClick={() => save(5000)}>
5 soniyaga saqlash
</Button>
<Button variant="outline" onClick={() => save()}>
Muddatsiz
</Button>
<Button variant="ghost" onClick={read}>
O'qish
</Button>
</div>
<p className="text-sm text-text-muted">
Natija: <code className="font-mono text-text">{result}</code>
</p>
</div>
);
}
createStorage
function createStorage(options?: CreateStorageOptions): TypedStorage;A wrapper around localStorage (or any other storage): it stores values as JSON, supports expiry
(ttl) and never throws. When storage is missing (SSR), blocked (private mode) or full, it
quietly does nothing: set returns false and get returns null.
import { createStorage } from '@akhatmirzo/core';
export const storage = createStorage({ prefix: 'app:' });
storage.set('token', 'abc', { ttl: 60 * 60 * 1000 }); // true — expires in 1 hour
storage.get<string>('token'); // 'abc'
storage.get<string[]>('recent', []); // [] — fallback when the key is missing
storage.set('user', { id: 7, name: 'Sarvar' }); // objects work too
storage.set('user', undefined); // removes the key
storage.remove('token');
storage.clear(); // only keys that start with "app:"| Prop | Type | Default | Description |
|---|---|---|---|
prefix | string | — | Prepended to every key, e.g. 'app:' |
storage | StorageLike | localStorage | sessionStorage, createMemoryStorage() or any object with the same interface |
TypedStorage methods:
| Method | What it does |
|---|---|
get<T>(key) | The value, or null (missing, expired or broken JSON) |
get<T>(key, fallback) | The value, or fallback |
set(key, value, { ttl? }) | Stores as JSON; ttl is in milliseconds. undefined removes the key. Returns boolean |
remove(key) | Removes the key |
clear() | Removes the prefixed keys (all keys when there's no prefix) |
Reading an expired entry returns null and removes it from storage.
Don't share a key with useLocalStorage
createStorage writes values inside a {"v": ..., "e": ...} envelope, while useLocalStorage
(hooks) writes plain JSON. If both read the same key, they won't
understand each other.
createMemoryStorage
function createMemoryStorage(): StorageLike;An in-memory storage — for tests, or for environments with no localStorage at all.
const storage = createStorage({ storage: createMemoryStorage() });API client
'use client';
import { createApiClient, isApiError, type FetchLike } from '@akhatmirzo/core';
import { Badge, Button } from '@akhatmirzo/ui';
import { useState } from 'react';
// Haqiqiy server o'rniga: `/status/404` → 404 javob, `/sekin` → hech qachon javob bermaydi
const fakeFetch: FetchLike = (url, init) =>
new Promise((resolve, reject) => {
if (url.endsWith('/sekin')) {
init.signal?.addEventListener('abort', () => reject(new Error('aborted')));
return;
}
const status = Number(url.split('/').pop());
resolve({ ok: status < 400, status, headers: { get: () => null }, text: async () => '' });
});
const api = createApiClient({ baseUrl: '/api', timeout: 1500, fetch: fakeFetch });
export default function CoreApiErrors() {
const [result, setResult] = useState<{ code: string; message: string } | null>(null);
const request = async (path: string) => {
try {
await api.get(path);
setResult({ code: 'OK', message: 'Muvaffaqiyatli' });
} catch (error) {
if (isApiError(error)) setResult({ code: error.code, message: error.message });
}
};
return (
<div className="flex w-full max-w-md flex-col gap-3">
<div className="flex flex-wrap gap-2">
{['401', '403', '404', '422', '500'].map((s) => (
<Button key={s} size="sm" variant="outline" onClick={() => request(`/status/${s}`)}>
{s}
</Button>
))}
<Button size="sm" variant="outline" onClick={() => request('/sekin')}>
Timeout
</Button>
</div>
{result && (
<div className="flex items-center gap-2 text-sm">
<Badge color={result.code === 'OK' ? 'success' : 'danger'}>{result.code}</Badge>
<span className="text-text">{result.message}</span>
</div>
)}
</div>
);
}
Click the buttons: every HTTP status and the timeout turn into the same ApiError type with an
Uzbek message. The demo uses a fake fetch instead of a real server.
createApiClient
function createApiClient(options: ApiClientOptions): ApiClient;A light wrapper around fetch. Here's what it does:
- builds the URL from
baseUrl+ path +query(undefinedandnullvalues are skipped); - serializes
bodyto JSON and setsContent-Type: application/json(strings are sent as is); - adds
Authorization: Bearer <token>whengetToken()returns a value; - returns JSON or text based on the response
Content-Type; 204, 205 or an empty body givesundefined; - turns every failure — HTTP errors, network, timeout, abort — into one
ApiErrortype.
import { createApiClient } from '@akhatmirzo/core';
import { storage } from './storage';
export const api = createApiClient({
baseUrl: 'https://api.example.uz/v1',
getToken: () => storage.get<string>('token'),
timeout: 10_000,
});interface Order {
id: number;
total: number;
}
const orders = await api.get<Order[]>('/orders', { query: { page: 2, status: ['new', 'paid'] } });
// GET https://api.example.uz/v1/orders?page=2&status=new&status=paid
const created = await api.post<Order>('/orders', { total: 1250000 });
await api.patch(`/orders/${created.id}`, { total: 1300000 });
await api.delete(`/orders/${created.id}`); // 204 → undefined| Prop | Type | Default | Description |
|---|---|---|---|
baseUrlrequired | string | — | For example 'https://api.example.uz/v1' or '/api' |
getToken | () => string | null | undefined | Promise<string | null | undefined> | — | Called on every request; a value becomes the Bearer token |
timeout | number | 15000 | Milliseconds; 0 disables it |
headers | Record<string, string> | — | Headers added to every request |
fetch | FetchLike | globalThis.fetch | Your own fetch (tests, Node, proxies) |
ApiClient methods: get(path, options?), delete(path, options?),
post(path, body?, options?), put(path, body?, options?), patch(path, body?, options?) and
the general request(method, path, options?). Per-request options:
| Prop | Type | Default | Description |
|---|---|---|---|
query | Record<string, QueryValue | readonly QueryValue[]> | — | undefined and null are skipped; an array repeats the key |
body | unknown | — | Only in request(); post/put/patch take it as the 2nd argument |
headers | Record<string, string> | — | Headers for this request |
signal | AbortSignal | — | Cancels the request |
timeout | number | — | Timeout for this request (ms); 0 disables it |
Handling errors
import { isApiError } from '@akhatmirzo/core';
import { toast } from '@akhatmirzo/ui';
try {
await api.post('/orders', form);
} catch (error) {
if (!isApiError(error)) throw error;
if (error.code === 'HTTP' && error.status === 401) return logout();
toast.error(error.message); // "Serverda xatolik. Keyinroq urinib ko'ring."
}ApiError
class ApiError extends Error {
name: string; // 'ApiError'
readonly status: number; // HTTP status; 0 for other errors
readonly code: 'HTTP' | 'NETWORK' | 'TIMEOUT' | 'ABORTED' | 'PARSE';
readonly data?: unknown; // the server response (JSON or text), if any
}
function isApiError(error: unknown): error is ApiError;code | When | message |
|---|---|---|
HTTP | The server returned 4xx or 5xx | The server's message field or a default |
NETWORK | Couldn't connect | "Ulanib bo'lmadi. Internetni tekshiring." |
TIMEOUT | Took longer than timeout | "So'rov vaqti tugadi." |
ABORTED | Cancelled through signal | "So'rov bekor qilindi." |
PARSE | The response claims JSON but is broken | "Server javobini o'qib bo'lmadi." |
For HTTP errors, if the server's JSON has a message string, that's what you get:
{ "message": "Buyurtma topilmadi" } → error.message === 'Buyurtma topilmadi'. Otherwise it's
httpErrorMessage(status).
httpErrorMessage
function httpErrorMessage(status: number): string;The default Uzbek message for an HTTP status:
| Status | Message | Meaning |
|---|---|---|
| 400 | So'rov noto'g'ri. | Bad request |
| 401 | Avtorizatsiyadan o'tilmagan. Qaytadan kiring. | Not signed in, please sign in again |
| 403 | Bu amal uchun ruxsat yo'q. | Not allowed |
| 404 | Topilmadi. | Not found |
| 409 | Ziddiyat: ma'lumot allaqachon o'zgargan. | Conflict: the data has changed |
| 422 | Ma'lumotlar noto'g'ri to'ldirilgan. | Invalid data |
| 429 | Juda ko'p so'rov. Birozdan so'ng urinib ko'ring. | Too many requests |
| 5xx | Serverda xatolik. Keyinroq urinib ko'ring. | Server error, try again later |
| other | So'rov bajarilmadi (418). | Request failed (status) |
API_MESSAGES is an object with the messages for non-HTTP errors (NETWORK, TIMEOUT,
ABORTED, PARSE).
buildUrl
function buildUrl(baseUrl: string, path: string, query?: QueryParams): string;The URL builder the client uses internally — handy on its own too, for example for a download
link. When path is a full URL, baseUrl is ignored.
buildUrl('/api/', '/users', { page: 2, q: undefined }); // '/api/users?page=2'
buildUrl('/api', 'orders', { status: ['new', 'paid'] }); // '/api/orders?status=new&status=paid'
buildUrl('/api', '/search?x=1', { page: 2 }); // '/api/search?x=1&page=2'
buildUrl('/api', 'https://cdn.example.uz/x.json'); // 'https://cdn.example.uz/x.json'