Chapter 9: GraphQL#
One Method, Two Worlds#
REST gives you URLs. GraphQL gives you a query language. Different philosophies, different trade-offs -- but your frontend shouldn't care. The api module you met in Chapter 6 already speaks GraphQL. Same auth. Same token rotation. Same error handling. One extra method and you're talking to a GraphQL endpoint.
No Apollo. No urql. No code generation. You write a query string, pass it to api.graphql(), and get back { data, errors }. The framework handles the plumbing.
1. Your First Query#
api.graphql() sends a POST with { query, variables } and returns a typed response.
import { api } from "tina4js";โapi.configure({ baseUrl: "/api", auth: true });โconst { data, errors } = await api.graphql("/graphql", "{ products { id name price } }");โif (errors) { console.error("GraphQL errors:", errors);} else { console.log(data.products);}The response shape never changes:
{ data: T | null; // Your result, or null on failure errors?: Array<{ message: string }>; // Present only when something went wrong}Query succeeds -- data holds your result, errors is undefined. Query fails -- data is null, errors tells you why. No status codes to decode. No response body formats to guess at.
2. Variables#
Hardcoded values in query strings invite injection and kill reusability. Variables fix both problems. Pass them as the third argument.
const { data } = await api.graphql("/graphql", `query ($limit: Int!, $offset: Int!) { products(limit: $limit, offset: $offset) { id name price stock } }`, { limit: 10, offset: 0 });A search query with user input:
const { data } = await api.graphql("/graphql", `query ($term: String!) { search_products(term: $term) { id name price } }`, { term: "widget" });The variable goes into the JSON body, not the query string. The server handles escaping. Your frontend stays clean.
3. Mutations#
Queries read. Mutations write. Same method, different keyword.
Create#
const { data } = await api.graphql("/graphql", `mutation ($input: ProductInput!) { create_product(input: $input) { id name price } }`, { input: { name: "New Widget", price: 29.99, category_id: 1, stock: 100 } });โconsole.log("Created:", data.create_product.id);Update#
const { data } = await api.graphql("/graphql", `mutation ($id: Int!, $input: ProductInput!) { update_product(id: $id, input: $input) { id name price } }`, { id: 42, input: { price: 24.99 } });Delete#
const { data } = await api.graphql("/graphql", `mutation ($id: Int!) { delete_product(id: $id) { success } }`, { id: 42 });Three operations. One method. The server reads the query or mutation keyword and knows what to do.
4. Authentication#
api.graphql() runs through the same auth pipeline as every other api method. Configure auth once and forget about it.
api.configure({ baseUrl: "/api", auth: true,});โ// The Bearer token rides alongconst { data } = await api.graphql("/graphql", "{ me { id name email role } }");Token rotation works too. The server sends a FreshToken header. The client stores it. The next request uses the fresh token. You never touch any of this.
5. Error Handling#
GraphQL responses carry two kinds of failure. The HTTP request itself can fail -- network down, server crashed, 500 response. Or the request succeeds but the query contains errors -- bad field name, permission denied, validation failure.
GraphQL Errors (query-level)#
The server returns 200 but errors is populated. data might still hold partial results.
const { data, errors } = await api.graphql("/graphql", `{ products { id name } categories { id name } }`);โif (errors) { for (const err of errors) { console.warn("GraphQL error:", err.message); }}โ// Products might have loaded even if categories failedif (data?.products) { renderProducts(data.products);}Network Errors (transport-level)#
The HTTP request never completed. This throws.
try { const { data } = await api.graphql("/graphql", "{ products { id } }");} catch (err) { console.error("Network error:", err);}Check errors for query problems. Catch exceptions for network problems. Handle both and your UI stays resilient.
6. Custom Headers#
Per-request headers and query params go in the fourth argument. Same options object as api.get() and api.post().
const { data } = await api.graphql("/graphql", "{ products { id name } }", {}, { headers: { "X-Request-Id": "abc123" }, params: { debug: "true" } });The empty object in position three means "no variables." The options follow.
7. Tina4 Backend Integration#
Every Tina4 backend -- Python, PHP, Ruby, Node.js -- generates a GraphQL endpoint from your ORM models. Register a model. The backend builds the schema. The frontend queries it. No SDL files. No resolvers to wire by hand.
The default endpoint is /api/graphql.
api.configure({ baseUrl: "" });โ// The backend generated this query from the User modelconst { data } = await api.graphql("/api/graphql", "{ users(limit: 10) { id name email } }");โ// Mutations are generated tooconst { data: created } = await api.graphql("/api/graphql", `mutation { create_user(name: "Alice", email: "alice@example.com") { id name email } }`);Your ORM models define the schema. The GraphQL layer reads it. The frontend consumes it. Three layers, zero duplication.
8. TypeScript#
api.graphql() accepts a type parameter. Pass an interface that describes the response shape and TypeScript narrows the return type.
interface ProductsResponse { products: Array<{ id: number; name: string; price: number; }>;}โconst { data } = await api.graphql<ProductsResponse>("/graphql", "{ products { id name price } }");โ// data is ProductsResponse | nulldata?.products.forEach(p => console.log(p.name));The type flows through destructuring. Your editor autocompletes data.products[0].name. No type assertions needed.
9. Reactive Queries with Signals#
GraphQL and signals fit together. A signal changes. An effect fires the query. The result lands in another signal. The UI updates.
import { signal, effect, html } from "tina4js";import { api } from "tina4js/api";โconst searchTerm = signal("");const products = signal([]);const loading = signal(false);โeffect(async () => { const term = searchTerm.value; if (term.length < 2) return;โ loading.value = true; const { data } = await api.graphql("/api/graphql", `query ($term: String!) { search_products(term: $term) { id name price } }`, { term } ); products.value = data?.search_products || []; loading.value = false;});โconst view = html` <input @input=${(e) => { searchTerm.value = e.target.value; }} placeholder="Search products..."> ${() => loading.value ? html`<p>Loading...</p>` : html`<ul>${() => products.value.map(p => html`<li>${p.name} - $${p.price}</li>` )}</ul>` }`;The user types. The signal updates. The effect queries. The list renders. No state management library. No cache normalisation layer. Signals and GraphQL, working together.
Summary#
| Call | What it does |
|---|---|
api.graphql(path, query) | Run a query |
api.graphql(path, query, variables) | Run a query with variables |
api.graphql(path, query, variables, options) | Run a query with custom headers or params |
One method. It sends { query, variables } as JSON. It returns { data, errors }. Auth, interceptors, and token rotation carry over from your api.configure() call. Everything you learned in Chapter 6 applies here -- GraphQL rides on the same transport.