Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

pyxpo-query

CI PyPI Python License: MIT

A server-state cache for pyxpo apps, written in pure Python. Screens ask for data by key; the cache loads it once, shares it between every screen that asks, keeps it while it is fresh, refetches it when it goes stale, when the app comes back to the foreground and when the network returns, and retries failed loads. Writes go through mutations, which can update the cache at once and then invalidate what they changed. If you know TanStack Query (React Query), you know this API.

from pyxpo import Text, component
from pyxpo_query import use_query


@component
def Posts():
    q = use_query(["posts"], api.list_posts, stale_time=60)
    if q.is_loading:
        return Text("Loading…")
    if q.is_error:
        return Text(str(q.error))
    return Text(", ".join(p["title"] for p in q.data))

Features

  • Cached queries: one load per key, shared by every screen showing it. Two screens asking at once share one request.
  • Freshness: stale_time says how long data counts as fresh; stale data shows at once and refreshes in the background.
  • Refetch on focus, reconnect, mount and interval, each one switchable per query.
  • Retries with exponential back-off, or your own rule (retry=lambda count, error: ...).
  • Offline aware: loads pause while offline and resume when the network returns.
  • Infinite lists: use_infinite_query loads page after page by cursor; a refetch reloads the pages in order.
  • Mutations: use_mutation with on_mutate for optimistic updates, on_error to roll back, and on_success/on_settled to invalidate.
  • Cache control: invalidate_queries, set_query_data, remove_queries, prefetch_query, ensure_query_data and key-prefix matching.
  • Garbage collection: unused data leaves the cache after gc_time.
  • Sync or async functions: query and mutation functions may be either.
  • No native code and no dependencies besides pyxpo. Works the same on Android, iOS, web and desktop.
  • Typed: ships py.typed.

Contents

If you are... Start here
Loading data on a screen Quick start → Queries
Deciding when data reloads Freshness and refetching
Building a feed that loads more on scroll Infinite lists
Saving changes Mutations
Wiring app focus and the network Focus and network
Looking for exact signatures API reference
Stuck Testing · Troubleshooting · FAQ

Platform support

Target What works
Android, API 24+ Everything.
iOS 15+ Everything.
Web page and desktop window (pyxpo run web / pyxpo run desktop) Everything.

Requires Python 3.9+ and pyxpo>=0.1.0a2.

Install

pip install pyxpo-query

Add the module to your app's pyproject.toml, so the build bundles its Python files:

[tool.pyxpo]
modules = ["query"]

The module has no native code and no config plugin. Rebuild once after adding it.

Quick start

Make one client for the app and put it above every screen:

from pyxpo import component
from pyxpo_query import QueryClient, QueryClientProvider

client = QueryClient(stale_time=60, retry=1)


@component
def App():
    return QueryClientProvider(Router(), client=client)

Then read data anywhere below it:

from pyxpo_query import use_query


@component
def Post(post_id):
    q = use_query(["post", post_id], lambda: api.get_post(post_id))
    ...

api.get_post may be a plain function or a coroutine function. The key is a list; anything JSON-like works in it, and dicts match regardless of order.

Queries

use_query(key, fn, **options) returns a QueryResult:

Field Meaning
data, error The last data, and the last error (None after a success).
status "pending" (no data yet), "error" or "success".
fetch_status "fetching", "paused" (offline) or "idle".
is_loading First load in flight: show a spinner.
is_refetching A background reload of data already shown.
is_pending, is_success, is_error, is_fetching, is_paused, is_stale Shorthands.
is_load_error, is_refetch_error The error came with no data yet, or on top of older data.
is_placeholder_data data is the placeholder, not this key's data.
refetch() Reloads now; returns the task, so await q.refetch() works.

Options:

Option Default Meaning
enabled True False waits (a dependent query: enabled=user_id is not None).
stale_time client's (0) Seconds data stays fresh.
gc_time client's (300) Seconds unused data stays cached.
retry, retry_delay client's (3, back-off) Count, True, or fn(count, error) -> bool; seconds or fn(count, error).
select None Maps data for this screen only.
initial_data None Data to start with, counted as loaded now.
placeholder_data None Shown while loading, not cached. keep_previous_data keeps the last key's data while a new page loads.
refetch_on_mount True False, True (when stale) or "always".
refetch_on_window_focus, refetch_on_reconnect client's (True) Reload stale data on these events.
refetch_interval None Seconds between polls while mounted.

Freshness and refetching

Data is fresh for stale_time seconds after it loads. Fresh data never reloads by itself. Stale data still shows at once, and reloads in the background when a screen mounts it, when the app returns to the foreground, and when the network comes back. invalidate_queries(key) marks data stale and reloads what is on screen.

client.invalidate_queries(["posts"])                      # every key starting with "posts"
client.invalidate_queries(["posts", project_id], exact=True)
client.invalidate_queries(predicate=lambda q: q.key[0] in ("home", "insights"))

Infinite lists

from pyxpo_query import use_infinite_query


@component
def Feed():
    q = use_infinite_query(
        ["feed"],
        lambda cursor: api.list_posts(cursor=cursor),
        initial_page_param=None,
        get_next_page_param=lambda last: last["pagination"]["nextCursor"] if last["pagination"]["hasNext"] else None,
    )
    return FlatList(data=q.items, on_end_reached=lambda: q.has_next_page and q.fetch_next_page(), ...)

q.data is InfiniteData(pages, page_params). q.items joins every page's list (pages that are lists, or have an items field). get_next_page_param gets (last_page, pages, last_param, params); take as many as you need. max_pages keeps only the newest pages. is_fetching_next_page tells a footer spinner from a full reload.

Mutations

from pyxpo_query import use_mutation, use_query_client


@component
def RenameButton(post):
    client = use_query_client()

    def optimistic(title):
        previous = client.get_query_data(["post", post["id"]])
        client.set_query_data(["post", post["id"]], lambda old: {**old, "title": title})
        return previous                                   # handed to on_error as `context`

    rename = use_mutation(
        lambda title: api.rename(post["id"], title),
        on_mutate=optimistic,
        on_error=lambda error, title, previous: client.set_query_data(["post", post["id"]], previous),
        on_settled=lambda *_: client.invalidate_queries(["posts"]),
    )
    return Button(title="Saving…" if rename.is_pending else "Rename", on_press=lambda: rename.mutate("New title"))

mutate(variables) runs in the background and keeps errors in rename.error. await mutate_async(variables) returns the data or raises. Mutations do not retry unless you pass retry. reset() goes back to "idle".

Focus and network

The client reloads stale data when focus_manager reports focus and online_manager reports the network. Wire them once at app start:

from pyxpo import AppState
from pyxpo_query import focus_manager, online_manager

AppState.addEventListener("change", lambda state: focus_manager.set_focused(state == "active"))
network.add_listener(lambda online: online_manager.set_online(online))   # from your network module

Production checklist

  • Make one QueryClient per app, outside components.
  • Set a stale_time (30 to 60 seconds suits most screens); 0 reloads on every mount.
  • Retry only errors that may pass: network errors and 5xx, not 4xx.
  • Put every input of the query function in its key, or screens will share the wrong data.
  • After a write, invalidate the lists it changes, not the whole cache.
  • On sign-out, call client.clear() so the next user never sees old data.

Testing

Render under a QueryClientProvider with a fresh client per test and the mock renderer on a live loop:

import asyncio

from pyxpo import LocalChannel, component
from pyxpo.app import App
from pyxpo.renderers.mock import MockRenderer
from pyxpo_query import QueryClient, QueryClientProvider


def test_posts():
    async def main():
        ch = LocalChannel()
        r = MockRenderer(ch)
        client = QueryClient(retry=0)

        @component
        def Root():
            return QueryClientProvider(Posts(), client=client)

        App(Root, ch, asyncio.get_running_loop()).start()
        await asyncio.sleep(0.05)
        client.unmount()
        return r.texts()

    assert asyncio.run(main()) == ["First post"]

retry=0 makes failures show at once. client.fetch_query and set_query_data also work in plain async tests.

Troubleshooting

Problem Fix
use_query needs a QueryClientProvider above it Wrap the app root in QueryClientProvider.
Two screens show each other's data Their keys are equal; add the missing inputs to the key.
Data never reloads stale_time is long or enabled is False. Invalidate after writes.
A spinner shows on every visit stale_time is 0 and there is no data in the cache yet; it is removed after gc_time with no screen using it.
Errors take seconds to show Retries with back-off; pass retry=0 for that query or test.

FAQ

Is it React Query? It follows its design and names (snake_case), not its code. Devtools, suspense, persisters and hydration are not there.

Where does the fetch run? On the app's asyncio loop. Async functions run there; sync functions run there too, so keep them quick or make them async.

Can I read the cache outside components? Yes: client.get_query_data, await client.fetch_query(...).

API reference

Name Purpose
QueryClient(*, stale_time=0, gc_time=300, retry=3, retry_delay=..., refetch_on_window_focus=True, refetch_on_reconnect=True, mutation_retry=0) The cache.
.get_query_data(key), .get_query_state(key) Read.
.set_query_data(key, value_or_updater), .set_queries_data(prefix, updater) Write; an updater returning None writes nothing.
await .fetch_query(key, fn, stale_time=0), .prefetch_query, .ensure_query_data Load outside components.
.invalidate_queries(key=None, exact=False, predicate=None, refetch_type="active") Mark stale and reload; returns tasks.
.refetch_queries, .cancel_queries, .remove_queries, .reset_queries, .clear() Cache control.
.is_fetching(key=None), .is_mutating() Counts.
.mount(), .unmount() Listen to focus and network; the provider calls mount.
QueryClientProvider(*children, client), use_query_client() Context.
use_query(key, fn, **options) -> QueryResult See Queries.
use_infinite_query(key, fn, *, initial_page_param, get_next_page_param, ...) -> InfiniteQueryResult See Infinite lists.
use_mutation(fn, *, on_mutate, on_success, on_error, on_settled, retry, retry_delay) -> MutationResult See Mutations.
use_is_fetching(key=None) -> int Number of loads in flight.
keep_previous_data A placeholder_data value.
focus_manager, online_manager .set_focused(bool), .set_online(bool), .subscribe(fn).
hash_key(key), matches_key(key, prefix, exact=False) Key helpers.

License

MIT. See LICENSE.

Metadata

Release files for pyxpo-query 0.1.0a1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pyxpo-query 0.1.0a1
File Size Uploaded Provenance Actions
pyxpo_query-0.1.0a1.tar.gz 26.0 kB With provenance Details for pyxpo_query-0.1.0a1.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for pyxpo-query 0.1.0a1
File Interpreter ABI Platform Actions
pyxpo_query-0.1.0a1-py3-none-any.whl Python 3 none any Details for pyxpo_query-0.1.0a1-py3-none-any.whl

Total release size: 45.5 kB

Release files / pyxpo_query-0.1.0a1.tar.gz

Download URL pyxpo_query-0.1.0a1.tar.gz
Size 26.0 kB
Tags Source
SHA-256 checksum
How to use checksums
fb19cb269ad31afa44d6a20a309c781709839d1447b4938a2537330ce8c19c14
BLAKE2b-256 checksum
How to use checksums
8724711d695e6b7ca0b525bf4e41f22042a44e792ea7db164dcc4f51f092d58e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 30, 2026.

Transparency log

Release files / pyxpo_query-0.1.0a1-py3-none-any.whl

Download URL pyxpo_query-0.1.0a1-py3-none-any.whl
Size 19.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
85c2339325ccedb9261d15f02d9075c5899c869f25b6f186ff69aa85475acdf5
BLAKE2b-256 checksum
How to use checksums
97b212832ce582151059aec104a9d1dddb920de0f8292429afa9588b0acd4c93
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 30, 2026.

Transparency log

Security for pyxpo-query 0.1.0a1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for pyxpo_query-0.1.0a1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 30, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page