Module 01 · about 60 min

Solana for JS devs + your first screen

Yesterday the template opened. Today we name the app, give it three tabs, and make Diagnostics ask devnet one question: are you up? Finish when you can point at the three tabs and the Devnet RPC chip says Connected or Unreachable.

1Three words you need today

Your phone does not store the chain. It asks a server. That request is an RPC (remote procedure call): a JSON body over HTTPS, one method name, one answer.

phone (Diagnostics tab)
  |  POST  { "jsonrpc":"2.0", "id":1, "method":"getHealth" }
  v
https://api.devnet.solana.com
  |  { "result": "ok" }     → green chip "Connected"
  |  anything else, or the request fails → red chip "Unreachable"

Devnet is Solana's public practice cluster. The SOL there is not the SOL people trade. We point the first check at https://api.devnet.solana.com so a broken experiment costs nothing. Mainnet is the cluster with real value; we only read it later.

An account is one record on the cluster, looked up by an address:

account
  address   the public key you look up
  owner     the program allowed to change its data
  lamports  the balance (1 SOL = 1,000,000,000 lamports)
  data      bytes the owner program stores

A wallet address is an account. A transaction is a signed list of changes to accounts. Today we do not read an account and we do not send a transaction. We only ask the cluster if its RPC is healthy. The wallet account shows up in the next module.

getHealth is a JSON-RPC method. The template already depends on @solana/web3.js 1.99.0. That package's Connection class has no getHealth method (it is not in the installed package). The check uses fetch and posts the method name getHealth. A healthy node returns result: "ok".

2Scaffold the app

This build started from the public template @solana-mobile/solana-mobile-expo-template version 3.0.0 (Expo 52, React Native 0.76, React Navigation bottom tabs, React Native Paper). It already includes @solana-mobile/mobile-wallet-adapter-protocol-web3js. You do not add a Solana library for this module.

npx create-expo-app ship-to-seeker-app --template @solana-mobile/solana-mobile-expo-template
cd ship-to-seeker-app
npm install
Development build, not Expo Go. Mobile Wallet Adapter needs native Android code. Expo Go cannot load it. Module 00's npm run android is how you put this on an emulator or a Seeker. You can still read the TypeScript on your laptop before that build finishes.

In app.json, set the display name and the package id:

"name": "Ship to Seeker",
"slug": "ship-to-seeker"

and under android / ios:

"package": "com.biboux.shiptoseeker"
"bundleIdentifier": "com.biboux.shiptoseeker"

If this is your own app, pick your own package name. Copying com.biboux.shiptoseeker only matches this repo.

Add an MIT LICENSE and, in the README, one line that the project is independent and is not affiliated with Solana Mobile, Radiants, or the Solana Foundation. Credit the template by name.

3Three tabs

The template's stack in src/navigators/AppNavigator.tsx already has a home route and a Settings screen. Leave Settings there. The template top bar typechecks because navigation.navigate("Settings") still exists. The three tabs set headerShown: false, so that top bar is not on these screens.

<Stack.Screen name="HomeStack" component={HomeNavigator} />
<Stack.Screen name="Settings" component={SettingsScreen} />

Replace src/navigators/HomeNavigator.tsx with three tabs. Icons are Feather from @expo/vector-icons (already installed with Expo: activity, list, message-circle).

import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import React from "react";
import Feather from "@expo/vector-icons/Feather";
import { BuildPathScreen } from "../screens/BuildPathScreen";
import { DiagnosticsScreen } from "../screens/DiagnosticsScreen";
import { ExplainerScreen } from "../screens/ExplainerScreen";

const Tab = createBottomTabNavigator();

type TabName = "Diagnostics" | "Build Path" | "Explainer";

const TAB_ICONS: Record<TabName, React.ComponentProps<typeof Feather>["name"]> = {
  Diagnostics: "activity",
  "Build Path": "list",
  Explainer: "message-circle",
};

export function HomeNavigator() {
  return (
    <Tab.Navigator
      screenOptions={({ route }) => ({
        headerShown: false,
        tabBarIcon: ({ color, size }) => (
          <Feather
            name={TAB_ICONS[route.name as TabName]}
            size={size}
            color={color}
          />
        ),
      })}
    >
      <Tab.Screen name="Diagnostics" component={DiagnosticsScreen} />
      <Tab.Screen name="Build Path" component={BuildPathScreen} />
      <Tab.Screen name="Explainer" component={ExplainerScreen} />
    </Tab.Navigator>
  );
}

Shared title block, src/components/ScreenIntro.tsx:

import React from "react";
import { StyleSheet, View } from "react-native";
import { Text } from "react-native-paper";

const INTRO_GAP = 4;

export function ScreenIntro({
  title,
  subtitle,
}: {
  title: string;
  subtitle: string;
}) {
  return (
    <View style={styles.intro}>
      <Text variant="headlineMedium">{title}</Text>
      <Text variant="bodyLarge">{subtitle}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  intro: {
    gap: INTRO_GAP,
    marginBottom: 20,
  },
});

Build Path and Explainer are empty on purpose. The checklist and the error box come on later days.

// src/screens/BuildPathScreen.tsx
import React from "react";
import { StyleSheet, View } from "react-native";
import { ScreenIntro } from "../components/ScreenIntro";

const SCREEN_PADDING = 16;

export function BuildPathScreen() {
  return (
    <View style={styles.screen}>
      <ScreenIntro title="Build Path" subtitle="Your 7-step build journey" />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: {
    flex: 1,
    padding: SCREEN_PADDING,
  },
});
// src/screens/ExplainerScreen.tsx
import React from "react";
import { StyleSheet, View } from "react-native";
import { ScreenIntro } from "../components/ScreenIntro";

const SCREEN_PADDING = 16;

export function ExplainerScreen() {
  return (
    <View style={styles.screen}>
      <ScreenIntro title="Explainer" subtitle="Paste an error, get a fix" />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: {
    flex: 1,
    padding: SCREEN_PADDING,
  },
});

src/screens/index.ts re-exports the three screens. Leave the template's HomeScreen.tsx on disk. We stopped using it as the first screen; the Connect button comes back when we wire the wallet.

4Devnet RPC check

src/diagnostics/devnetRpc.ts. Eight second timeout. Green only when HTTP is 2xx, the JSON has no error, and result is exactly "ok".

export const DEVNET_RPC_URL = "https://api.devnet.solana.com";
export const RPC_HEALTH_TIMEOUT_MS = 8_000;
export const RPC_UNREACHABLE_HINT =
  "Check your internet connection or try a different RPC endpoint";

export const RPC_HEALTH_METHOD = "getHealth";

export type DevnetRpcChip = "connected" | "unreachable";

type JsonRpcHealthBody = {
  result?: unknown;
  error?: unknown;
};

export function chipFromHealthResponse(
  httpStatus: number,
  body: JsonRpcHealthBody | null,
): DevnetRpcChip {
  if (httpStatus < 200 || httpStatus >= 300) {
    return "unreachable";
  }
  if (body == null || body.error != null) {
    return "unreachable";
  }
  return body.result === "ok" ? "connected" : "unreachable";
}

export async function checkDevnetRpcHealth(
  fetchImpl: typeof fetch = fetch,
  endpoint: string = DEVNET_RPC_URL,
): Promise<DevnetRpcChip> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), RPC_HEALTH_TIMEOUT_MS);
  try {
    const response = await fetchImpl(endpoint, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: RPC_HEALTH_METHOD,
      }),
      signal: controller.signal,
    });

    let body: JsonRpcHealthBody | null = null;
    try {
      body = (await response.json()) as JsonRpcHealthBody;
    } catch (parseError) {
      console.warn("Devnet RPC health response was not JSON", parseError);
      return "unreachable";
    }

    return chipFromHealthResponse(response.status, body);
  } catch (error) {
    console.warn("Devnet RPC health check failed", error);
    return "unreachable";
  } finally {
    clearTimeout(timer);
  }
}

src/screens/DiagnosticsScreen.tsx runs that check when the screen mounts. Retry bumps a counter so the effect runs again. The chip is green Connected or red Unreachable. The hint shows only when the check fails.

import React, { useCallback, useEffect, useState } from "react";
import { StyleSheet, View } from "react-native";
import { Button, Card, Chip, Text } from "react-native-paper";
import { ScreenIntro } from "../components/ScreenIntro";
import {
  RPC_UNREACHABLE_HINT,
  checkDevnetRpcHealth,
  type DevnetRpcChip,
} from "../diagnostics/devnetRpc";

type CheckState = "checking" | DevnetRpcChip;

const SCREEN_PADDING = 16;
const CHIP_CONNECTED = "#1B7F3A";
const CHIP_UNREACHABLE = "#B42318";
const CHIP_CHECKING = "#5C6570";
const CHIP_LABEL = "#FFFFFF";

export function DiagnosticsScreen() {
  const [attempt, setAttempt] = useState(0);
  const [status, setStatus] = useState<CheckState>("checking");

  useEffect(() => {
    let cancelled = false;
    setStatus("checking");

    checkDevnetRpcHealth()
      .then((next) => {
        if (!cancelled) {
          setStatus(next);
        }
      })
      .catch((error: unknown) => {
        console.warn("Devnet RPC check did not settle", error);
        if (!cancelled) {
          setStatus("unreachable");
        }
      });

    return () => {
      cancelled = true;
    };
  }, [attempt]);

  const retry = useCallback(() => {
    setAttempt((current) => current + 1);
  }, []);

  const chipLabel =
    status === "checking"
      ? "Checking"
      : status === "connected"
        ? "Connected"
        : "Unreachable";
  const chipColor =
    status === "connected"
      ? CHIP_CONNECTED
      : status === "unreachable"
        ? CHIP_UNREACHABLE
        : CHIP_CHECKING;

  return (
    <View style={styles.screen}>
      <ScreenIntro
        title="Diagnostics"
        subtitle="Check your Solana Mobile setup"
      />
      <Card mode="outlined">
        <Card.Title title="Devnet RPC" subtitle="getHealth on api.devnet.solana.com" />
        <Card.Content>
          <Chip
            style={{ backgroundColor: chipColor }}
            textStyle={styles.chipText}
          >
            {chipLabel}
          </Chip>
          {status === "unreachable" ? (
            <Text variant="bodyMedium" style={styles.hint}>
              {RPC_UNREACHABLE_HINT}
            </Text>
          ) : null}
        </Card.Content>
        <Card.Actions>
          <Button
            mode="contained-tonal"
            onPress={retry}
            disabled={status === "checking"}
            loading={status === "checking"}
          >
            Retry
          </Button>
        </Card.Actions>
      </Card>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: {
    flex: 1,
    padding: SCREEN_PADDING,
  },
  chipText: {
    color: CHIP_LABEL,
  },
  hint: {
    marginTop: 12,
  },
});

Optional check on your laptop, same rules as src/diagnostics/devnetRpc.test.ts:

npm test
npx tsc --noEmit

5Checkpoint

You are done with Module 01 when you can say the three tab names out loud, and Diagnostics shows a chip for Devnet RPC:

Unreachable with the hint "Check your internet connection or try a different RPC endpoint" still counts. That means the request failed or the body was not result: "ok". Public devnet is sometimes busy. Hit Retry.

6If it breaks