A lightweight, dependency-free data fetching and caching library optimized for Smart TV applications. Built with performance and simplicity in mind, it provides powerful features without the bloat of larger libraries.
- 🚀 Lightweight - Zero dependencies, tree-shakeable, optimized for Smart TV performance
- 💾 Smart Caching - Configurable staleTime and cacheTime with automatic cache management
- 🔄 Request Deduplication - Automatic deduplication of concurrent identical requests
- ⚡ React Hooks -
useQuery,useMutation,useInfiniteQueryfor seamless integration - 🔌 XHR & Fetch Support - Built-in XHR fetcher with progress tracking and abort support
- ♾️ Infinite Queries - Built-in support for paginated and infinite scroll data
- 🎯 Window Focus Refetch - Automatically refetch stale data when window regains focus
- 🔧 TypeScript First - Full TypeScript support with excellent type inference
- 🪶 Small Bundle Size - Minimal footprint for faster load times on TV devices
Install the package using your preferred package manager:
# npm
npm install @smart-tv/query
# pnpm
pnpm add @smart-tv/query
# yarn
yarn add @smart-tv/queryWrap your app with QueryClientProvider and create a QueryClient instance:
import { QueryClient, QueryClientProvider } from "@smart-tv/query";
const queryClient = new QueryClient({
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 10, // 10 minutes
retry: 3, // Retry failed requests 3 times
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}Use useQuery to fetch and cache data:
import { useQuery } from "@smart-tv/query";
function Movies() {
const { data, error, status, refetch } = useQuery(["movies"], () =>
fetch("/api/movies").then((res) => res.json())
);
if (status === "loading") return <div>Loading...</div>;
if (status === "error") return <div>Error: {error.message}</div>;
return (
<div>
{data.map((movie) => (
<div key={movie.id}>{movie.title}</div>
))}
</div>
);
}The QueryClient is the core of the library. It manages cache, handles deduplication, and coordinates all queries.
import { QueryClient } from "@smart-tv/query";
const queryClient = new QueryClient({
staleTime: 1000 * 60 * 5, // Data is fresh for 5 minutes
cacheTime: 1000 * 60 * 10, // Cache persists for 10 minutes after unused
retry: 3, // Retry failed requests 3 times
enabled: true, // Enable queries by default
keepPreviousData: false, // Whether to keep previous data during refetch
});Configuration Options:
| Option | Type | Default | Description |
|---|---|---|---|
staleTime |
number | 0 | Time in ms before data is considered stale |
cacheTime |
number | 5min | Time in ms before unused cache is garbage collected |
retry |
number | 0 | Number of retry attempts for failed requests |
enabled |
boolean | true | Enable/disable queries globally |
keepPreviousData |
boolean | false | Keep previous data during refetch |
refetchOnWindowFocus |
boolean | false | Refetch when window regains focus |
refetchOnMount |
boolean | true | Refetch on component mount if stale |
Fetch and cache data with automatic cache management.
const { data, error, status, refetch } = useQuery(queryKey, queryFn, options);Parameters:
queryKey:string | readonly unknown[]- Unique identifier for the queryqueryFn:() => Promise<T>- Function that returns a promise with dataoptions:QueryOptions<T>- Optional configuration (overrides client defaults)
Returns:
data:T | undefined- The fetched dataerror:unknown | undefined- Error if the query failedstatus:'idle' | 'loading' | 'success' | 'error'- Current query statusrefetch:() => Promise<T>- Function to manually refetch data
Example with dynamic parameters:
function MovieDetails({ movieId }) {
const { data, status } = useQuery(
["movie", movieId],
() => fetch(`/api/movies/${movieId}`).then((res) => res.json()),
{
staleTime: 1000 * 60 * 10, // 10 minutes
enabled: !!movieId, // Only fetch if movieId exists
}
);
if (status === "loading") return <Spinner />;
return <div>{data.title}</div>;
}Example with data transformation:
const { data } = useQuery(
["movies"],
() => fetch("/api/movies").then((res) => res.json()),
{
select: (data) => data.filter((movie) => movie.rating > 4),
}
);Execute mutations (POST, PUT, DELETE) with success/error callbacks.
const { mutate, data, error, status } = useMutation(mutationFn, options);Parameters:
mutationFn:(variables: TVariables) => Promise<TData>- Function that performs the mutationoptions:MutationOptions<TData, TVariables>- Optional callbacks
Returns:
mutate:(variables: TVariables) => Promise<TData>- Function to trigger the mutationdata:TData | undefined- Response data from the mutationerror:unknown | undefined- Error if mutation failedstatus:'idle' | 'loading' | 'success' | 'error'- Current mutation status
Example:
import { useMutation } from "@smart-tv/query";
function AddMovieForm() {
const { mutate, status } = useMutation(
(newMovie) =>
fetch("/api/movies", {
method: "POST",
body: JSON.stringify(newMovie),
headers: { "Content-Type": "application/json" },
}).then((res) => res.json()),
{
onSuccess: (data) => {
console.log("Movie added:", data);
// Invalidate and refetch movies list
queryClient.invalidateQueries(["movies"], { refetch: true });
},
onError: (error) => {
console.error("Failed to add movie:", error);
},
}
);
const handleSubmit = (movie) => {
mutate(movie);
};
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<button type="submit" disabled={status === "loading"}>
{status === "loading" ? "Adding..." : "Add Movie"}
</button>
</form>
);
}Fetch paginated data with infinite scroll support.
const { data, isFetching, fetchNext, hasNextPage } = useInfiniteQuery(
queryKey,
fetchPageFn,
options
);Parameters:
queryKey:QueryKey- Unique identifier for the infinite queryfetchPageFn:(cursor?: string | number | null) => Promise<TPage>- Function to fetch each pageoptions:InfiniteQueryOptions<TItem>- Configuration options
Returns:
data:TItem[]- Flattened array of all items from all pagesisFetching:boolean- Whether currently fetching a pagefetchNext:() => Promise<void>- Function to fetch the next pagehasNextPage:boolean- Whether there are more pages to fetch
Example:
import { useInfiniteQuery } from "@smart-tv/query";
function MovieList() {
const { data, isFetching, fetchNext, hasNextPage } = useInfiniteQuery(
["movies", "infinite"],
async (cursor) => {
const res = await fetch(`/api/movies?cursor=${cursor || ""}`);
return res.json();
},
{
mapPage: (raw) => ({
items: raw.results,
nextCursor: raw.nextCursor,
}),
getHasNext: (pages) => {
const lastPage = pages[pages.length - 1];
return !!lastPage?.nextCursor;
},
}
);
return (
<div>
{data.map((movie) => (
<MovieCard key={movie.id} movie={movie} />
))}
{hasNextPage && (
<button onClick={fetchNext} disabled={isFetching}>
{isFetching ? "Loading..." : "Load More"}
</button>
)}
</div>
);
}Invalidate queries to mark them as stale and optionally refetch.
// Invalidate and refetch a specific query
queryClient.invalidateQueries(["movies"], { refetch: true });
// Invalidate without refetching
queryClient.invalidateQueries(["movies"]);
// Invalidate all queries
queryClient.invalidateQueries();Get cached data for a specific query.
const cachedMovies = queryClient.getQueryData(["movies"]);Manually update cached data.
// Direct update
queryClient.setQueryData(["movies"], newMoviesArray);
// Update with function
queryClient.setQueryData(["movies"], (oldData) => {
return [...oldData, newMovie];
});Imperatively fetch a query (useful outside of React components).
const movies = await queryClient.fetchQuery(
["movies"],
() => fetch("/api/movies").then((res) => res.json()),
{ staleTime: 1000 * 60 }
);The library includes a powerful XHR-based fetcher with progress tracking and timeout support:
import { xhrFetcher, tvFetch } from "@smart-tv/query";
// Basic usage
const { data } = useQuery(["movie", id], () =>
xhrFetcher(`/api/movies/${id}`, {
method: "GET",
headers: { Authorization: "Bearer token" },
})
);
// With progress tracking
const { mutate } = useMutation((file) =>
xhrFetcher("/upload", {
method: "POST",
body: file,
timeout: 30000, // 30 seconds
onUploadProgress: (sent, total) => {
console.log(`Uploaded ${sent} / ${total}`);
},
})
);
// With abort signal
const controller = new AbortController();
xhrFetcher("/api/data", { signal: controller.signal });
// Later: controller.abort()XHR Options:
method: HTTP method (GET, POST, PUT, DELETE, etc.)headers: Request headersbody: Request body (auto-stringified for objects)responseType: Response type ('json', 'text', 'blob', 'arraybuffer')timeout: Request timeout in millisecondswithCredentials: Include cookies in cross-origin requestsonUploadProgress: Upload progress callbackonDownloadProgress: Download progress callbacksignal: AbortSignal for request cancellation
Automatically refetch stale data when the TV app regains focus:
const queryClient = new QueryClient({
refetchOnWindowFocus: true,
});
// Or per-query
const { data } = useQuery(["movies"], fetchMovies, {
refetchOnWindowFocus: true,
staleTime: 1000 * 60 * 5, // Only refetch if older than 5 minutes
});Update UI optimistically before mutation completes:
const { mutate } = useMutation(
(updatedMovie) =>
fetch(`/api/movies/${updatedMovie.id}`, {
method: "PUT",
body: JSON.stringify(updatedMovie),
}),
{
onSuccess: (newData) => {
// Update cache with server response
queryClient.setQueryData(["movie", newData.id], newData);
queryClient.invalidateQueries(["movies"]);
},
}
);
// Optimistic update before mutation
const handleUpdate = (movie) => {
// Update cache immediately
queryClient.setQueryData(["movie", movie.id], movie);
// Then trigger mutation
mutate(movie);
};Execute queries that depend on other queries:
// First query
const { data: user } = useQuery(["user"], fetchUser);
// Second query depends on first
const { data: posts } = useQuery(
["posts", user?.id],
() => fetchUserPosts(user.id),
{
enabled: !!user?.id, // Only run when user.id exists
}
);Execute multiple queries in parallel:
function Dashboard() {
const movies = useQuery(["movies"], fetchMovies);
const shows = useQuery(["shows"], fetchShows);
const trending = useQuery(["trending"], fetchTrending);
if (movies.status === "loading" || shows.status === "loading") {
return <Spinner />;
}
return (
<div>
<MovieSection data={movies.data} />
<ShowSection data={shows.data} />
<TrendingSection data={trending.data} />
</div>
);
}Here's a comprehensive example showing various features:
import {
QueryClient,
QueryClientProvider,
useQuery,
useMutation,
} from "@smart-tv/query";
// Create client
const queryClient = new QueryClient({
staleTime: 1000 * 60 * 5,
cacheTime: 1000 * 60 * 10,
retry: 3,
refetchOnWindowFocus: true,
});
// API functions
const fetchMovies = () => fetch("/api/movies").then((res) => res.json());
const fetchMovie = (id) => fetch(`/api/movies/${id}`).then((res) => res.json());
const addToWatchlist = (movieId) =>
fetch("/api/watchlist", {
method: "POST",
body: JSON.stringify({ movieId }),
headers: { "Content-Type": "application/json" },
}).then((res) => res.json());
// Components
function MovieList() {
const { data, status, refetch } = useQuery(["movies"], fetchMovies, {
select: (data) => data.filter((m) => m.rating > 3),
});
if (status === "loading") return <Spinner />;
if (status === "error") return <Error />;
return (
<div>
<button onClick={refetch}>Refresh</button>
{data.map((movie) => (
<MovieCard key={movie.id} movie={movie} />
))}
</div>
);
}
function MovieDetails({ movieId }) {
const { data } = useQuery(["movie", movieId], () => fetchMovie(movieId), {
enabled: !!movieId,
});
const { mutate, status } = useMutation(addToWatchlist, {
onSuccess: () => {
queryClient.invalidateQueries(["watchlist"]);
alert("Added to watchlist!");
},
});
return (
<div>
<h1>{data?.title}</h1>
<button onClick={() => mutate(movieId)} disabled={status === "loading"}>
Add to Watchlist
</button>
</div>
);
}
// App
function App() {
return (
<QueryClientProvider client={queryClient}>
<MovieList />
</QueryClientProvider>
);
}Full TypeScript support with excellent type inference:
import { useQuery, QueryClient, QueryOptions } from "@smart-tv/query";
interface Movie {
id: number;
title: string;
rating: number;
}
// Type-safe query
const { data } = useQuery<Movie[]>(["movies"], () =>
fetch("/api/movies").then((res) => res.json())
);
// data is typed as Movie[] | undefined
// Type-safe mutation
const { mutate } = useMutation<Movie, { title: string }>((newMovie) =>
fetch("/api/movies", {
method: "POST",
body: JSON.stringify(newMovie),
}).then((res) => res.json())
);
// mutate expects { title: string }For comprehensive documentation, interactive examples, and best practices, visit:
📚 https://smart-tv-docs.vercel.app/components/query
- Use appropriate staleTime: Set longer staleTime (5-10 minutes) for data that doesn't change frequently
- Implement pagination: Use
useInfiniteQueryfor large datasets - Enable window focus refetch: Ensure users see fresh data when returning to the app
- Optimize cache size: Set reasonable cacheTime to prevent memory issues on low-end devices
- Use XHR fetcher: Better control over requests with timeout and abort support
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- Smart TV browsers (Tizen 4.0+, webOS 4.0+)
# Install dependencies
pnpm install
# Build
pnpm --filter=@smart-tv/query build
# Development mode
pnpm --filter=@smart-tv/query devContributions are welcome! Please follow the monorepo conventions and add tests for new features.
See CONTRIBUTING.md for more details.
- @smart-tv/ui - React component library for Smart TV apps
- @smart-tv/player - Video player with focus support
- create-smart-tv - CLI to scaffold Smart TV projects
BSD 3-Clause License - see LICENSE for details.
Made with ❤️ for Smart TV developers