Combining Supabase's powerful backend services with Nucleux's atomic state management creates a clean, scalable architecture for React Native applications. This guide shows you how to structure authentication and API services using class-based stores that naturally organize your application logic.
The Architecture Pattern
We'll build a three-layer architecture that separates concerns cleanly:
-
ApiService
: Handles Supabase client configuration and lifecycle -
SessionStore
: Manages authentication state and user sessions -
Auth
Component: Provides the user interface for authentication
This pattern leverages Nucleux's dependency injection to create a clean separation where each layer has a single responsibility.
Setting Up the API Service
First, create a centralized API service that manages the Supabase client:
// src/services/ApiService.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createClient, SupabaseClient } from "@supabase/supabase-js";
import { Store } from "nucleux";
import { AppState, NativeEventSubscription, Platform } from "react-native";
import "react-native-url-polyfill/auto";
if (
process.env.EXPO_PUBLIC_SUPABASE_URL === undefined ||
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY === undefined
) {
throw new Error("Missing critical environment variables");
}
class ApiService extends Store {
public apiClient: SupabaseClient;
private appStateSubscription: NativeEventSubscription;
constructor() {
super();
this.apiClient = createClient(
process.env.EXPO_PUBLIC_SUPABASE_URL || "",
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || "",
{
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
}
);
this.appStateSubscription = AppState.addEventListener("change", (state) => {
if (state === "active") {
this.apiClient.auth.startAutoRefresh();
} else {
this.apiClient.auth.stopAutoRefresh();
}
});
}
destroy() {
this.appStateSubscription.remove();
super.destroy();
}
}
export default ApiService;
Key Benefits:
- Centralized Configuration: Single place to manage Supabase setup
- Lifecycle Management: Properly manages token refresh based on app state
Creating the Session Store
The SessionStore
handles all authentication logic while staying completely decoupled from UI concerns:
// src/stores/SessionStore.ts
import ApiService from "@/services/ApiService";
import { Session, Subscription } from "@supabase/supabase-js";
import { Store } from "nucleux";
class SessionStore extends Store {
public session = this.atom<Session | null>(null);
public initialized = this.atom<boolean>(false);
private apiService = this.inject(ApiService);
private authSubscription: Subscription;
constructor() {
super();
this.getSession();
this.authSubscription = this.apiService.apiClient.auth.onAuthStateChange(
(_, session) => {
this.setSession(session);
}
).data.subscription;
}
private async getSession() {
try {
const { data: { session } } = await this.apiService.apiClient.auth.getSession();
this.setSession(session);
} finally {
this.initialized.value = true;
}
}
private setSession(session: Session | null) {
this.session.value = session;
}
public async signIn(email: string, password: string) {
return this.apiService.apiClient.auth.signInWithPassword({
email,
password,
});
}
public async signOut() {
return this.apiService.apiClient.auth.signOut();
}
destroy() {
this.authSubscription.unsubscribe();
super.destroy();
}
}
export default SessionStore;
Architecture Highlights:
-
Dependency Injection:
this.inject(ApiService)
automatically provides the API client -
Atomic State: Only components using
session
orinitialized
re-render when these values change - Event Handling: Supabase auth changes automatically update the atomic state
- Clean API: Simple methods for sign in/out that any component can use
Building the Auth Component
Finally, create a simple authentication component that consumes the session store:
// components/Auth.tsx
import SessionStore from "@/stores/SessionStore";
import { useStore, useValue } from "nucleux";
import React, { useState } from "react";
import { Alert, StyleSheet, Text, TextInput, TouchableOpacity, View } from "react-native";
export default function Auth() {
const sessionStore = useStore(SessionStore);
const session = useValue(sessionStore.session);
const initialized = useValue(sessionStore.initialized);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const handleSignIn = async () => {
if (!email || !password) {
Alert.alert("Error", "Please fill in all fields");
return;
}
setLoading(true);
try {
const { error } = await sessionStore.signIn(email, password);
if (error) Alert.alert("Error", error.message);
} finally {
setLoading(false);
}
};
const handleSignOut = async () => {
const { error } = await sessionStore.signOut();
if (error) Alert.alert("Error", error.message);
};
if (!initialized) {
return <Text>Loading...</Text>;
}
if (session) {
return (
<View style={styles.container}>
<Text style={styles.title}>Welcome back!</Text>
<Text>Signed in as: {session.user.email}</Text>
<TouchableOpacity style={styles.button} onPress={handleSignOut}>
<Text style={styles.buttonText}>Sign Out</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>Sign In</Text>
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleSignIn}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? "Signing In..." : "Sign In"}
</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, justifyContent: "center" },
title: { fontSize: 24, fontWeight: "bold", marginBottom: 20, textAlign: "center" },
input: { borderWidth: 1, borderColor: "#ddd", padding: 12, marginBottom: 10, borderRadius: 5 },
button: { backgroundColor: "#007AFF", padding: 12, borderRadius: 5, marginTop: 10 },
buttonDisabled: { opacity: 0.5 },
buttonText: { color: "white", textAlign: "center", fontWeight: "bold" },
});
Why This Architecture Works
1. Clear Separation of Concerns
Each layer has a single responsibility, making the code easier to test and maintain.
2. Automatic Reactivity
Components automatically re-render when authentication state changes, without manual subscription management.
3. Reusable Business Logic
The SessionStore
can be used across multiple components without duplicating authentication logic.
4. Type Safety
TypeScript ensures you catch authentication-related bugs at compile time.
5. Easy Testing
Each store can be unit tested independently of React components.
The Result
This architecture gives you a robust foundation for React Native apps that need authentication. The SessionStore
becomes the single source of truth for authentication state, while the component layer focuses purely on user interface concerns.
Want to explore more patterns? Check out the complete Nucleux documentation and see how this same approach scales to complex applications with multiple data stores and API services.
Ready to build your next React Native app? Try combining Nucleux with Supabase for a powerful, type-safe development experience.
Top comments (0)