@akhatmirzo/ui

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: —

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.

lib/storage.ts
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:"
PropTypeDefaultDescription
prefixstring—Prepended to every key, e.g. 'app:'
storageStorageLikelocalStoragesessionStorage, createMemoryStorage() or any object with the same interface

TypedStorage methods:

MethodWhat 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

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 (undefined and null values are skipped);
  • serializes body to JSON and sets Content-Type: application/json (strings are sent as is);
  • adds Authorization: Bearer <token> when getToken() returns a value;
  • returns JSON or text based on the response Content-Type; 204, 205 or an empty body gives undefined;
  • turns every failure — HTTP errors, network, timeout, abort — into one ApiError type.
lib/api.ts
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
PropTypeDefaultDescription
baseUrlrequiredstring—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
timeoutnumber15000Milliseconds; 0 disables it
headersRecord<string, string>—Headers added to every request
fetchFetchLikeglobalThis.fetchYour 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:

PropTypeDefaultDescription
queryRecord<string, QueryValue | readonly QueryValue[]>—undefined and null are skipped; an array repeats the key
bodyunknown—Only in request(); post/put/patch take it as the 2nd argument
headersRecord<string, string>—Headers for this request
signalAbortSignal—Cancels the request
timeoutnumber—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;
codeWhenmessage
HTTPThe server returned 4xx or 5xxThe server's message field or a default
NETWORKCouldn't connect"Ulanib bo'lmadi. Internetni tekshiring."
TIMEOUTTook longer than timeout"So'rov vaqti tugadi."
ABORTEDCancelled through signal"So'rov bekor qilindi."
PARSEThe 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:

StatusMessageMeaning
400So'rov noto'g'ri.Bad request
401Avtorizatsiyadan o'tilmagan. Qaytadan kiring.Not signed in, please sign in again
403Bu amal uchun ruxsat yo'q.Not allowed
404Topilmadi.Not found
409Ziddiyat: ma'lumot allaqachon o'zgargan.Conflict: the data has changed
422Ma'lumotlar noto'g'ri to'ldirilgan.Invalid data
429Juda ko'p so'rov. Birozdan so'ng urinib ko'ring.Too many requests
5xxServerda xatolik. Keyinroq urinib ko'ring.Server error, try again later
otherSo'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'

On this page