Displaying Products

Fetch live store products with useProducts and build your own paywall UI.

If you want full control over your paywall UI instead of using a hosted paywall, fetch your products directly and render them with your own React Native components. Voidhash resolves the products declared in your schema against the native store SDK (StoreKit on iOS, Google Play Billing on Android), so every product comes back with live, store-formatted pricing.

Fetching Products

Use the useProducts() hook returned by createVoidhashClient. It returns { data, error, isLoading } and automatically waits for the Provider to finish initializing:

app/paywall.tsx
import { Text, View } from "react-native";
import { voidhash } from "utils/voidhash/client";

export default function PaywallScreen() {
  const { data: products, error, isLoading } = voidhash.useProducts();

  if (isLoading) {
    return <LoadingSpinner />;
  }

  if (error) {
    return <ErrorMessage error={error} />;
  }

  return (
    <View>
      {products.toList().map((product) => (
        <View key={product.slug}>
          <Text>{product.name}</Text>
          <Text>{product.displayPrice}</Text>
        </View>
      ))}
    </View>
  );
}

Working with the Result

data is a record keyed by your product slugs, with two helpers attached:

  • data.get(slug) — returns the product for a slug, or null if it isn't available (or products haven't loaded yet).
  • data.toList() — returns all products as an array, filtering out any null entries.
const { data: products } = voidhash.useProducts();

// Look up a specific product
const monthly = products.get("pro-monthly");

// Or iterate over everything the store returned
const available = products.toList();

If you've generated types with voidhash-cli types generate, the slugs are typed as a literal union — products.get() autocompletes your real product slugs and rejects typos at compile time. See Schema & Types.

Product Fields

Each product combines your schema definition with live metadata from the store:

FieldDescription
slugThe product's slug from your Voidhash schema.
idThe native store product identifier (App Store Connect / Google Play product ID).
nameThe product name from your Voidhash schema.
displayNameThe localized product name from the store listing.
displayPriceThe store-formatted price string, e.g. $9.99 — already localized to the user's storefront.
priceThe price as a number.
currencyThe storefront currency code.
typeThe product type reported by the store SDK.
platform"ios" or "android".
googlePlayOfferTokenAndroid only — the offer token used when purchasing.

Always render displayPrice

Prefer displayPrice over formatting price yourself. The store formats it for the user's storefront — correct currency symbol, decimal separators, and locale conventions — which is very hard to get right manually.

Product Types

In your schema, every product is one of three types:

  • subscription — auto-renewing subscription.
  • one-time — a single purchase that permanently unlocks its perks.
  • one-time-consumable — a purchase that can be bought repeatedly (credits, coins, boosts).

Complete Paywall Example

A full paywall with product selection and purchase. Track the selection by slug and resolve the selected product from the list — this avoids having to store the product object in state:

app/paywall.tsx
import { useState } from "react";
import { Button, Text, TouchableOpacity, View } from "react-native";
import { voidhash } from "utils/voidhash/client";

export default function PaywallScreen() {
  const [selectedSlug, setSelectedSlug] = useState<string | null>(null);

  const { data: products, isLoading: areProductsLoading } = voidhash.useProducts();
  const { purchase, isLoading: isPurchasing } = voidhash.usePurchase();

  if (areProductsLoading) {
    return <LoadingSpinner />;
  }

  const selectedProduct = products.toList().find((product) => product.slug === selectedSlug) ?? null;

  const handlePurchase = () => {
    if (!selectedProduct) {
      return;
    }
    purchase(selectedProduct);
  };

  return (
    <View>
      <Text>Choose a plan to continue</Text>

      <View>
        {products.toList().map((product) => (
          <TouchableOpacity key={product.slug} onPress={() => setSelectedSlug(product.slug)}>
            <View>
              <Text>{product.name}</Text>
              <Text>{product.displayPrice}</Text>
            </View>
          </TouchableOpacity>
        ))}
      </View>

      <Button
        onPress={handlePurchase}
        disabled={!selectedProduct || isPurchasing}
        title={isPurchasing ? "Purchasing..." : "Continue"}
      />
    </View>
  );
}

See Making Purchases for the full purchase flow, including callbacks and error handling.

Troubleshooting: a Product Is null

The products record always contains an entry for every product in your schema, but the value is null when the store SDK doesn't know about that product. Since toList() filters out null entries, this often shows up as a paywall with missing (or no) products.

null products almost always mean store misconfiguration

Voidhash asks the store for the product IDs configured in your schema. If the store doesn't return metadata for one, the most common causes are:

  • The store product ID in your schema (appleAppStore.productId, or googlePlay.productId + basePlanId) doesn't exactly match the ID in App Store Connect / Google Play Console.
  • The product hasn't been created yet, or isn't in a ready/active state in the store console.
  • The product has no provider configuration for the current platform — a product configured only for appleAppStore resolves to null on Android, and vice versa.
  • Store-side prerequisites are missing, such as unsigned paid app agreements in App Store Connect or an app that hasn't been published to a testing track in Google Play Console.

Fetched store products are cached for up to 24 hours. If you've just fixed the store-side configuration and still see null, clear the app's storage or reinstall the development build to force a fresh fetch. Changing the schema itself (for example, correcting a product ID) invalidates the cache automatically.

Fetching Imperatively

Outside of React, use client.getProducts(). It returns the same slug-keyed record (without the get/toList helpers) and rejects with a FAILED_TO_GET_PRODUCTS error if the fetch fails:

import { voidhash } from "utils/voidhash/client";

const products = await voidhash.client.getProducts();
const monthly = products["pro-monthly"];

The client must be initialized first — calling it before the Provider has mounted throws a VOIDHASH_CLIENT_NOT_INITIALIZED error. See Error Handling for the error conventions.

Next Steps