This part builds upon the previous parts by demonstrating how to perform GraphQL operations with proper database permissions. You’ll learn how to design database tables, configure user permissions, and implement complete CRUD operations through GraphQL queries and mutations in a real todos application.
This is Part 4 in the Full-Stack React Native Development with Nhost series. This part focuses on GraphQL operations, database management, and permission-based data access control in a production application.

Full-Stack React Native Development with Nhost

Prerequisites

What You’ll Build

By the end of this part, you’ll have:
  • GraphQL queries and mutations for complete CRUD operations
  • Database schema with proper relationships and constraints
  • User permissions for secure data access control
  • React Native screens that interact with GraphQL endpoint

Step-by-Step Guide

1

Create the To-Dos Table

First, we’ll perform the database changes to set up the todos table with proper schema and relationships to users.In your Nhost project dashboard:
  1. Navigate to Database
  2. Click on the SQL Editor
Enter the following SQL:
CREATE TABLE public.todos (
  id uuid DEFAULT gen_random_uuid() NOT NULL,
  created_at timestamptz DEFAULT now() NOT NULL,
  updated_at timestamptz DEFAULT now() NOT NULL,
  title text NOT NULL,
  details text,
  completed bool DEFAULT false NOT NULL,
  user_id uuid NOT NULL,
  PRIMARY KEY (id),
  FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE CASCADE ON DELETE CASCADE
);


CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$ language 'plpgsql';


CREATE TRIGGER update_todos_updated_at
  BEFORE UPDATE ON public.todos
  FOR EACH ROW
  EXECUTE FUNCTION update_updated_at_column();

Please make sure to enable Track this so that the new table todos is available through the auto-generated APIs
2

Set Up Permissions

It’s now time to set permission rules for the table you just created. With the table todos selected, click on , followed by Edit Permissions. You will set permissions for the user role and actions insert, select, update, and delete.
When inserting permissions we are only allowing users to set the title, details, and completed columns as the rest of the columns are set automatically by the backend. The user_id column is configured as a preset to the currently authenticated user’s ID using the X-Hasura-User-Id session variable. This ensures that each todo is associated with the user who created it.Insert Permissions Configuration
3

Create the Todos Screen Component

Now let’s implement the React Native screen that uses the database we just configured.
app/todos.tsx
import { router, Stack } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import {
  ActivityIndicator,
  Alert,
  FlatList,
  Text,
  TextInput,
  TouchableOpacity,
  View,
} from "react-native";
import ProtectedScreen from "./components/ProtectedScreen";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles } from "./styles/commonStyles";
import { colors } from "./styles/theme";

// The interfaces below define the structure of our data
// They are not strictly necessary but help with type safety

// Represents a single todo item
interface Todo {
  id: string;
  title: string;
  details: string | null;
  completed: boolean;
  created_at: string;
  updated_at: string;
  user_id: string;
}

// This matches the GraphQL response structure for fetching todos
// Can be used as a generic type on the request method
interface GetTodos {
  todos: Todo[];
}

// This matches the GraphQL response structure for inserting a todo
// Can be used as a generic type on the request method
interface InsertTodo {
  insert_todos_one: Todo | null;
}

// This matches the GraphQL response structure for updating a todo
// Can be used as a generic type on the request method
interface UpdateTodo {
  update_todos_by_pk: Todo | null;
}

export default function Todos() {
  const { nhost, session } = useAuth();
  const [todos, setTodos] = useState<Todo[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [newTodoTitle, setNewTodoTitle] = useState("");
  const [newTodoDetails, setNewTodoDetails] = useState("");
  const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
  const [showAddForm, setShowAddForm] = useState(false);
  const [expandedTodos, setExpandedTodos] = useState<Set<string>>(new Set());
  const [addingTodo, setAddingTodo] = useState(false);
  const [updatingTodos, setUpdatingTodos] = useState<Set<string>>(new Set());

  // Redirect to sign in if not authenticated
  useEffect(() => {
    if (!session) {
      router.replace("/signin");
    }
  }, [session]);

  const fetchTodos = useCallback(async () => {
    try {
      setLoading(true);
      // Make GraphQL request to fetch todos using Nhost client
      // The query automatically filters by user_id due to Hasura permissions
      const response = await nhost.graphql.request<GetTodos>({
        query: `
          query GetTodos {
            todos(order_by: { created_at: desc }) {
              id
              title
              details
              completed
              created_at
              updated_at
              user_id
            }
          }
        `,
      });

      // Check for GraphQL errors in the response body
      if (response.body.errors) {
        throw new Error(
          response.body.errors[0]?.message || "Failed to fetch todos",
        );
      }

      // Extract todos from the GraphQL response data
      setTodos(response.body?.data?.todos || []);
      setError(null);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to fetch todos");
    } finally {
      setLoading(false);
    }
  }, [nhost.graphql]);

  const addTodo = async () => {
    if (!newTodoTitle.trim()) return;

    try {
      setAddingTodo(true);
      // Execute GraphQL mutation to insert a new todo
      // user_id is automatically set by Hasura based on JWT token
      const response = await nhost.graphql.request<InsertTodo>({
        query: `
          mutation InsertTodo($title: String!, $details: String) {
            insert_todos_one(object: { title: $title, details: $details }) {
              id
              title
              details
              completed
              created_at
              updated_at
              user_id
            }
          }
        `,
        variables: {
          title: newTodoTitle.trim(),
          details: newTodoDetails.trim() || null,
        },
      });

      if (response.body.errors) {
        throw new Error(
          response.body.errors[0]?.message || "Failed to add todo",
        );
      }

      if (!response.body?.data?.insert_todos_one) {
        throw new Error("Failed to add todo");
      }
      setTodos([response.body?.data?.insert_todos_one, ...todos]);
      setNewTodoTitle("");
      setNewTodoDetails("");
      setShowAddForm(false);
      setError(null);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to add todo");
      Alert.alert(
        "Error",
        err instanceof Error ? err.message : "Failed to add todo",
      );
    } finally {
      setAddingTodo(false);
    }
  };

  const updateTodo = async (
    id: string,
    updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
  ) => {
    try {
      setUpdatingTodos((prev) => new Set([...prev, id]));
      // Execute GraphQL mutation to update an existing todo by primary key
      // Hasura permissions ensure users can only update their own todos
      const response = await nhost.graphql.request<UpdateTodo>({
        query: `
          mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
            update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
              id
              title
              details
              completed
              created_at
              updated_at
              user_id
            }
          }
        `,
        variables: {
          id,
          updates,
        },
      });

      if (response.body.errors) {
        throw new Error(
          response.body.errors[0]?.message || "Failed to update todo",
        );
      }

      if (!response.body?.data?.update_todos_by_pk) {
        throw new Error("Failed to update todo");
      }

      const updatedTodo = response.body?.data?.update_todos_by_pk;
      if (updatedTodo) {
        setTodos(todos.map((todo) => (todo.id === id ? updatedTodo : todo)));
      }
      setEditingTodo(null);
      setError(null);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to update todo");
      Alert.alert(
        "Error",
        err instanceof Error ? err.message : "Failed to update todo",
      );
    } finally {
      setUpdatingTodos((prev) => {
        const newSet = new Set(prev);
        newSet.delete(id);
        return newSet;
      });
    }
  };

  const deleteTodo = async (id: string) => {
    Alert.alert("Delete Todo", "Are you sure you want to delete this todo?", [
      { text: "Cancel", style: "cancel" },
      {
        text: "Delete",
        style: "destructive",
        onPress: async () => {
          try {
            setUpdatingTodos((prev) => new Set([...prev, id]));
            // Execute GraphQL mutation to delete a todo by primary key
            // Hasura permissions ensure users can only delete their own todos
            const response = await nhost.graphql.request({
              query: `
                  mutation DeleteTodo($id: uuid!) {
                    delete_todos_by_pk(id: $id) {
                      id
                    }
                  }
                `,
              variables: {
                id,
              },
            });

            if (response.body.errors) {
              throw new Error(
                response.body.errors[0]?.message || "Failed to delete todo",
              );
            }

            setTodos(todos.filter((todo) => todo.id !== id));
            setError(null);
          } catch (err) {
            setError(
              err instanceof Error ? err.message : "Failed to delete todo",
            );
            Alert.alert(
              "Error",
              err instanceof Error ? err.message : "Failed to delete todo",
            );
          } finally {
            setUpdatingTodos((prev) => {
              const newSet = new Set(prev);
              newSet.delete(id);
              return newSet;
            });
          }
        },
      },
    ]);
  };

  const toggleComplete = async (todo: Todo) => {
    await updateTodo(todo.id, { completed: !todo.completed });
  };

  const saveEdit = async () => {
    if (!editingTodo) return;
    await updateTodo(editingTodo.id, {
      title: editingTodo.title,
      details: editingTodo.details,
    });
  };

  const toggleTodoExpansion = (todoId: string) => {
    const newExpanded = new Set(expandedTodos);
    if (newExpanded.has(todoId)) {
      newExpanded.delete(todoId);
    } else {
      newExpanded.add(todoId);
    }
    setExpandedTodos(newExpanded);
  };

  // Fetch todos when user session is available
  // The session contains the JWT token needed for GraphQL authentication
  useEffect(() => {
    if (session) {
      fetchTodos();
    }
  }, [session, fetchTodos]);

  if (!session) {
    return null; // Will redirect to sign in
  }

  const renderTodoItem = ({ item: todo }: { item: Todo }) => {
    const isUpdating = updatingTodos.has(todo.id);
    const isExpanded = expandedTodos.has(todo.id);

    return (
      <View
        style={[
          commonStyles.todoCard,
          todo.completed && commonStyles.todoCompleted,
        ]}
      >
        {editingTodo?.id === todo.id ? (
          <View style={commonStyles.todoEditForm}>
            <Text style={commonStyles.inputLabel}>Title</Text>
            <TextInput
              style={commonStyles.input}
              value={editingTodo.title}
              onChangeText={(text) =>
                setEditingTodo({
                  ...editingTodo,
                  title: text,
                })
              }
              placeholder="Enter todo title"
              placeholderTextColor={colors.textPlaceholder}
            />
            <Text style={commonStyles.inputLabel}>Details</Text>
            <TextInput
              style={[commonStyles.input, commonStyles.textArea]}
              value={editingTodo.details || ""}
              onChangeText={(text) =>
                setEditingTodo({
                  ...editingTodo,
                  details: text,
                })
              }
              placeholder="Enter details (optional)"
              placeholderTextColor={colors.textPlaceholder}
              multiline
              numberOfLines={3}
            />
            <View style={commonStyles.buttonGroup}>
              <TouchableOpacity
                style={[commonStyles.button, commonStyles.primaryButton]}
                onPress={saveEdit}
                disabled={isUpdating}
              >
                <Text style={commonStyles.buttonText}>
                  {isUpdating ? "Saving..." : "Save"}
                </Text>
              </TouchableOpacity>
              <TouchableOpacity
                style={[commonStyles.button, commonStyles.secondaryButton]}
                onPress={() => setEditingTodo(null)}
              >
                <Text
                  style={[
                    commonStyles.buttonText,
                    commonStyles.secondaryButtonText,
                  ]}
                >
                  Cancel
                </Text>
              </TouchableOpacity>
            </View>
          </View>
        ) : (
          <View>
            <View style={commonStyles.todoHeader}>
              <TouchableOpacity
                style={commonStyles.todoTitleContainer}
                onPress={() => toggleTodoExpansion(todo.id)}
              >
                <Text
                  style={[
                    commonStyles.todoTitle,
                    todo.completed && commonStyles.todoTitleCompleted,
                  ]}
                >
                  {todo.title}
                </Text>
              </TouchableOpacity>
              <View style={commonStyles.todoActions}>
                <TouchableOpacity
                  style={[
                    commonStyles.actionButton,
                    commonStyles.completeButton,
                  ]}
                  onPress={() => toggleComplete(todo)}
                  disabled={isUpdating}
                >
                  <Text style={commonStyles.actionButtonText}>
                    {isUpdating ? "⌛" : todo.completed ? "↶" : "✓"}
                  </Text>
                </TouchableOpacity>
                <TouchableOpacity
                  style={[commonStyles.actionButton, commonStyles.editButton]}
                  onPress={() => setEditingTodo(todo)}
                >
                  <Text style={commonStyles.actionButtonText}>✏️</Text>
                </TouchableOpacity>
                <TouchableOpacity
                  style={[commonStyles.actionButton, commonStyles.deleteButton]}
                  onPress={() => deleteTodo(todo.id)}
                  disabled={isUpdating}
                >
                  <Text style={commonStyles.actionButtonText}>🗑️</Text>
                </TouchableOpacity>
              </View>
            </View>
            {isExpanded && (
              <View style={commonStyles.todoDetails}>
                {todo.details && (
                  <Text
                    style={[
                      commonStyles.todoDescription,
                      todo.completed && commonStyles.todoDescriptionCompleted,
                    ]}
                  >
                    {todo.details}
                  </Text>
                )}
                <View style={commonStyles.todoMeta}>
                  <Text style={commonStyles.metaText}>
                    Created: {new Date(todo.created_at).toLocaleString()}
                  </Text>
                  <Text style={commonStyles.metaText}>
                    Updated: {new Date(todo.updated_at).toLocaleString()}
                  </Text>
                  {todo.completed && (
                    <View style={commonStyles.completionBadge}>
                      <Text style={commonStyles.completionText}>
                        ✅ Completed
                      </Text>
                    </View>
                  )}
                </View>
              </View>
            )}
          </View>
        )}
      </View>
    );
  };

  const renderHeader = () => (
    <>
      <View style={commonStyles.pageHeader}>
        <Text style={commonStyles.pageTitle}>My Todos</Text>
        {!showAddForm && (
          <TouchableOpacity
            style={commonStyles.addButton}
            onPress={() => setShowAddForm(true)}
          >
            <Text style={commonStyles.addButtonText}>+</Text>
          </TouchableOpacity>
        )}
      </View>

      {error && (
        <View style={[commonStyles.errorContainer, { marginHorizontal: 16 }]}>
          <Text style={commonStyles.errorText}>Error: {error}</Text>
        </View>
      )}

      {showAddForm && (
        <View style={[commonStyles.card, { marginHorizontal: 16, width: undefined }]}>
          <Text style={commonStyles.cardTitle}>Add New Todo</Text>
          <View style={commonStyles.formFields}>
            <View style={commonStyles.fieldGroup}>
              <Text style={commonStyles.inputLabel}>Title *</Text>
              <TextInput
                style={commonStyles.input}
                value={newTodoTitle}
                onChangeText={setNewTodoTitle}
                placeholder="What needs to be done?"
                placeholderTextColor={colors.textPlaceholder}
              />
            </View>
            <View style={commonStyles.fieldGroup}>
              <Text style={commonStyles.inputLabel}>Details</Text>
              <TextInput
                style={[commonStyles.input, commonStyles.textArea]}
                value={newTodoDetails}
                onChangeText={setNewTodoDetails}
                placeholder="Add some details (optional)..."
                placeholderTextColor={colors.textPlaceholder}
                multiline
                numberOfLines={3}
              />
            </View>
            <View style={commonStyles.buttonGroup}>
              <TouchableOpacity
                style={[commonStyles.button, commonStyles.primaryButton]}
                onPress={addTodo}
                disabled={addingTodo || !newTodoTitle.trim()}
              >
                <Text style={commonStyles.buttonText}>
                  {addingTodo ? "Adding..." : "Add Todo"}
                </Text>
              </TouchableOpacity>
              <TouchableOpacity
                style={[commonStyles.button, commonStyles.secondaryButton]}
                onPress={() => {
                  setShowAddForm(false);
                  setNewTodoTitle("");
                  setNewTodoDetails("");
                }}
              >
                <Text
                  style={[
                    commonStyles.buttonText,
                    commonStyles.secondaryButtonText,
                  ]}
                >
                  Cancel
                </Text>
              </TouchableOpacity>
            </View>
          </View>
        </View>
      )}
    </>
  );

  const renderEmptyState = () => (
    <View style={commonStyles.emptyState}>
      <Text style={commonStyles.emptyStateTitle}>No todos yet</Text>
      <Text style={commonStyles.emptyStateText}>
        Create your first todo to get started!
      </Text>
    </View>
  );

  if (loading) {
    return (
      <ProtectedScreen>
        <Stack.Screen options={{ title: "My Todos" }} />
        <View style={commonStyles.loadingContainer}>
          <ActivityIndicator size="large" color="#6366f1" />
          <Text style={commonStyles.loadingText}>Loading todos...</Text>
        </View>
      </ProtectedScreen>
    );
  }

  return (
    <ProtectedScreen>
      <Stack.Screen options={{ title: "My Todos" }} />
      <View style={commonStyles.container}>
        {renderHeader()}
        <FlatList
          data={showAddForm ? [] : todos}
          renderItem={renderTodoItem}
          keyExtractor={(item) => item.id}
          ListEmptyComponent={!showAddForm ? renderEmptyState : null}
          showsVerticalScrollIndicator={false}
          contentContainerStyle={commonStyles.listContainer}
          keyboardShouldPersistTaps="handled"
        />
      </View>
    </ProtectedScreen>
  );
}
4

Update Home Screen Navigation

Since React Native uses file-based routing with Expo Router, you can add navigation to the todos screen from your home screen or any other screen using the router.push() method.
app/index.tsx
import { useRouter } from "expo-router";
import { Alert, Text, TouchableOpacity, View } from "react-native";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles, homeStyles } from "./styles/commonStyles";

export default function Index() {
  const router = useRouter();
  const { isAuthenticated, session, nhost, user } = useAuth();

  const handleSignOut = async () => {
    Alert.alert("Sign Out", "Are you sure you want to sign out?", [
      { text: "Cancel", style: "cancel" },
      {
        text: "Sign Out",
        style: "destructive",
        onPress: async () => {
          try {
            if (session) {
              await nhost.auth.signOut({
                refreshToken: session.refreshToken,
              });
            }
            router.replace("/");
          } catch (err: unknown) {
            const message = err instanceof Error ? err.message : String(err);
            Alert.alert("Error", `Failed to sign out: ${message}`);
          }
        },
      },
    ]);
  };

  return (
    <View style={commonStyles.centerContent}>
      <Text style={commonStyles.title}>Welcome to Nhost React Native Demo</Text>

      <View style={homeStyles.welcomeCard}>
        {isAuthenticated ? (
          <View style={{ gap: 15, width: "100%" }}>
            <Text style={homeStyles.welcomeText}>
              Hello, {user?.displayName || user?.email}!
            </Text>

            <TouchableOpacity
              style={[commonStyles.button, commonStyles.fullWidth]}
              onPress={() => router.push("/todos")}
            >
              <Text style={commonStyles.buttonText}>My Todos</Text>
            </TouchableOpacity>

            <TouchableOpacity
              style={[commonStyles.button, commonStyles.fullWidth]}
              onPress={() => router.push("/profile")}
            >
              <Text style={commonStyles.buttonText}>Go to Profile</Text>
            </TouchableOpacity>

            <TouchableOpacity
              style={[commonStyles.button, { backgroundColor: "#ef4444" }]}
              onPress={handleSignOut}
            >
              <Text style={commonStyles.buttonText}>Sign Out</Text>
            </TouchableOpacity>
          </View>
        ) : (
          <>
            <Text style={homeStyles.authMessage}>You are not signed in.</Text>

            <View style={{ gap: 15, width: "100%" }}>
              <TouchableOpacity
                style={[commonStyles.button, commonStyles.fullWidth]}
                onPress={() => router.push("/signin")}
              >
                <Text style={commonStyles.buttonText}>Sign In</Text>
              </TouchableOpacity>

              <TouchableOpacity
                style={[
                  commonStyles.button,
                  commonStyles.buttonSecondary,
                  commonStyles.fullWidth,
                ]}
                onPress={() => router.push("/signup")}
              >
                <Text style={commonStyles.buttonText}>Sign Up</Text>
              </TouchableOpacity>
            </View>
          </>
        )}
      </View>
    </View>
  );
}
5

Test Your Complete Application

Run your React Native application and test all the functionality:
npx expo start
Things to try out:
  1. Try signing in and out and see how the Todos screen is only available when authenticated
  2. Create, view, edit, complete, and delete todos. See how the UI updates accordingly with React Native animations
  3. Test the mobile experience - long press, swipe gestures, and keyboard interactions
  4. Test on different devices/simulators to see how the todos look on various screen sizes
  5. Try signing out and signing in with a different account to verify that you cannot see or modify todos from other accounts

Key Features Implemented