Edit this page
A tutorial on creating a native module that persists settings with Expo Modules API.
In this tutorial, we are going to build a module that stores the user's preferred app theme: either dark, light, or system. We'll use UserDefaults
on iOS and SharedPreferences
on Android. It is possible to implement web support using localStorage
, but we'll leave that as an exercise for the reader.
Learn how to create a native module with the Expo Modules API to extend your apps capabilities by accessing native Android and iOS APIs.
First, we'll create a new module. On this page we will use the name expo-settings
/ExpoSettings
. You can name it whatever you like, just adjust the instructions accordingly:
-
npx create-expo-module expo-settings
Tip: Since you aren't going to actually ship this library, you can hit return for all the prompts to accept the default values.
Now let's clean up the default module a little bit so we have more of a clean slate and delete the view module that we won't use in this guide.
-
cd expo-settings
-
rm ios/ExpoSettingsView.swift
-
rm android/src/main/java/expo/modules/settings/ExpoSettingsView.kt
-
rm src/ExpoSettingsView.tsx src/ExpoSettings.types.ts
-
rm src/ExpoSettingsView.web.tsx src/ExpoSettingsModule.web.ts
Find the following files and replace them with the provided minimal boilerplate:
import ExpoModulesCore
public class ExpoSettingsModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoSettings")
Function("getTheme") { () -> String in
"system"
}
}
}
package expo.modules.settings
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoSettingsModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoSettings")
Function("getTheme") {
return@Function "system"
}
}
}
import ExpoSettingsModule from './ExpoSettingsModule';
export function getTheme(): string {
return ExpoSettingsModule.getTheme();
}
import * as Settings from 'expo-settings';
import { Text, View } from 'react-native';
export default function App() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text>Theme: {Settings.getTheme()}</Text>
</View>
);
}
Now let's run the example project to make sure everything is working. We'll need to start the TypeScript compiler to watch for changes and rebuild the module JavaScript, and separately in another terminal window we'll compile and run the example app.
# Run this in the root of the project to start the TypeScript compiler
-
npm run build
-
cd example
# Run the example app on iOS
-
npx expo run:ios
# Run the example app on Android
-
npx expo run:android
We should now see the text "Theme: system" in the center of the screen when we launch the example app. The value "system"
is the result of synchronously calling the getTheme()
function in the native module. We'll change this value in the next step.
To read the value on iOS, we can look for a UserDefaults
string under the key "theme"
, and fall back to "system"
if there isn't any.
To set the value, we can use UserDefaults
's set(_:forKey:)
method. We'll make our setTheme
function accept a value of type String
.
import ExpoModulesCore
public class ExpoSettingsModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoSettings")
Function("setTheme") { (theme: String) -> Void in
UserDefaults.standard.set(theme, forKey:"theme")
}
Function("getTheme") { () -> String in
UserDefaults.standard.string(forKey: "theme") ?? "system"
}
}
}
To read the value, we can look for a SharedPreferences
string under the key "theme"
, and fall back to "system"
if there isn't any. We can get the SharedPreferences
instance from the reactContext
(a React Native ContextWrapper) using getSharedPreferences()
.
To set the value, we can use SharedPreferences
's edit()
method to get an Editor
instance, and then use putString()
to set the value. We'll make our setTheme
function accept a value of type String
.
package expo.modules.settings
import android.content.Context
import android.content.SharedPreferences
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoSettingsModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoSettings")
Function("setTheme") { theme: String ->
getPreferences().edit().putString("theme", theme).commit()
}
Function("getTheme") {
return@Function getPreferences().getString("theme", "system")
}
}
private val context
get() = requireNotNull(appContext.reactContext)
private fun getPreferences(): SharedPreferences {
return context.getSharedPreferences(context.packageName + ".settings", Context.MODE_PRIVATE)
}
}
Now we can call our native modules from TypeScript.
import ExpoSettingsModule from './ExpoSettingsModule';
export function getTheme(): string {
return ExpoSettingsModule.getTheme();
}
export function setTheme(theme: string): void {
return ExpoSettingsModule.setTheme(theme);
}
We can now use the Settings
API in our example app.
import * as Settings from 'expo-settings';
import { Button, Text, View } from 'react-native';
export default function App() {
const theme = Settings.getTheme();
// Toggle between dark and light theme
const nextTheme = theme === 'dark' ? 'light' : 'dark';
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text>Theme: {Settings.getTheme()}</Text>
<Button title={`Set theme to ${nextTheme}`} onPress={() => Settings.setTheme(nextTheme)} />
</View>
);
}
When we re-build and run the app, we'll see the "system" theme is still set. When we press the button, nothing happens. When you reload the app, you'll see the theme has changed. This is because we're never fetching the new theme value and re-rendering the app. We'll fix this in the next step.
We can ensure that developers using our API can react to changes in the theme value by emitting a change event when the value changes. We'll use the Events definition component to describe events that our module can emit, sendEvent
to emit the event from native, and the EventEmitter API to subscribe to events in JavaScript. Our event payload will be { theme: string }
.
import ExpoModulesCore
public class ExpoSettingsModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoSettings")
Events("onChangeTheme")
Function("setTheme") { (theme: String) -> Void in
UserDefaults.standard.set(theme, forKey:"theme")
sendEvent("onChangeTheme", [
"theme": theme
])
}
Function("getTheme") { () -> String in
UserDefaults.standard.string(forKey: "theme") ?? "system"
}
}
}
Events payloads are represented as Bundle
instances on Android, which we can create using the bundleOf
function.
package expo.modules.settings
import android.content.Context
import android.content.SharedPreferences
import androidx.core.os.bundleOf
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoSettingsModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoSettings")
Events("onChangeTheme")
Function("setTheme") { theme: String ->
getPreferences().edit().putString("theme", theme).commit()
this@ExpoSettingsModule.sendEvent("onChangeTheme", bundleOf("theme" to theme))
}
Function("getTheme") {
return@Function getPreferences().getString("theme", "system")
}
}
private val context
get() = requireNotNull(appContext.reactContext)
private fun getPreferences(): SharedPreferences {
return context.getSharedPreferences(context.packageName + ".settings", Context.MODE_PRIVATE)
}
}
import { EventSubscription } from 'expo-modules-core';
import ExpoSettingsModule from './ExpoSettingsModule';
export type ThemeChangeEvent = {
theme: string;
};
export function addThemeListener(listener: (event: ThemeChangeEvent) => void): EventSubscription {
return ExpoSettingsModule.addListener('onChangeTheme', listener);
}
export function getTheme(): string {
return ExpoSettingsModule.getTheme();
}
export function setTheme(theme: string): void {
return ExpoSettingsModule.setTheme(theme);
}
import * as Settings from 'expo-settings';
import { useEffect, useState } from 'react';
import { Button, Text, View } from 'react-native';
export default function App() {
const [theme, setTheme] = useState<string>(Settings.getTheme());
useEffect(() => {
const subscription = Settings.addThemeListener(({ theme: newTheme }) => {
setTheme(newTheme);
});
return () => subscription.remove();
}, [setTheme]);
// Toggle between dark and light theme
const nextTheme = theme === 'dark' ? 'light' : 'dark';
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text>Theme: {Settings.getTheme()}</Text>
<Button title={`Set theme to ${nextTheme}`} onPress={() => Settings.setTheme(nextTheme)} />
</View>
);
}
It's easy for us to make a mistake when using the Settings.setTheme()
API in its current form, because we can set the theme to any string value. We can improve the type safety of this API by using an enum to restrict the possible values to system
, light
, and dark
.
import ExpoModulesCore
public class ExpoSettingsModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoSettings")
Events("onChangeTheme")
Function("setTheme") { (theme: Theme) -> Void in
UserDefaults.standard.set(theme.rawValue, forKey:"theme")
sendEvent("onChangeTheme", [
"theme": theme.rawValue
])
}
Function("getTheme") { () -> String in
UserDefaults.standard.string(forKey: "theme") ?? Theme.system.rawValue
}
}
enum Theme: String, Enumerable {
case light
case dark
case system
}
}
package expo.modules.settings
import android.content.Context
import android.content.SharedPreferences
import androidx.core.os.bundleOf
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.types.Enumerable
class ExpoSettingsModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoSettings")
Events("onChangeTheme")
Function("setTheme") { theme: Theme ->
getPreferences().edit().putString("theme", theme.value).commit()
this@ExpoSettingsModule.sendEvent("onChangeTheme", bundleOf("theme" to theme.value))
}
Function("getTheme") {
return@Function getPreferences().getString("theme", Theme.SYSTEM.value)
}
}
private val context
get() = requireNotNull(appContext.reactContext)
private fun getPreferences(): SharedPreferences {
return context.getSharedPreferences(context.packageName + ".settings", Context.MODE_PRIVATE)
}
}
enum class Theme(val value: String) : Enumerable {
LIGHT("light"),
DARK("dark"),
SYSTEM("system")
}
import { EventSubscription } from 'expo-modules-core';
import ExpoSettingsModule from './ExpoSettingsModule';
export type Theme = 'light' | 'dark' | 'system';
export type ThemeChangeEvent = {
theme: Theme;
};
export function addThemeListener(listener: (event: ThemeChangeEvent) => void): EventSubscription {
return ExpoSettingsModule.addListener('onChangeTheme', listener);
}
export function getTheme(): Theme {
return ExpoSettingsModule.getTheme();
}
export function setTheme(theme: Theme): void {
return ExpoSettingsModule.setTheme(theme);
}
If we change Settings.setTheme(nextTheme)
to Settings.setTheme("not-a-real-theme")
, TypeScript will complain, and if we ignore that and go ahead and press the button, we'll see the following error:
ERROR Error: FunctionCallException: Calling the 'setTheme' function has failed (at ExpoModulesCore/SyncFunctionComponent.swift:76)
→ Caused by: ArgumentCastException: Argument at index '0' couldn't be cast to type Enum<Theme> (at ExpoModulesCore/JavaScriptUtils.swift:41)
→ Caused by: EnumNoSuchValueException: 'not-a-real-theme' is not present in Theme enum, it must be one of: 'light', 'dark', 'system' (at ExpoModulesCore/Enumerable.swift:37)
We can see from the last line of the error message that not-a-real-theme
is not a valid value for the Theme
enum, and that light
, dark
, and system
are the only valid values.
Congratulations! You have created your first simple yet non-trivial Expo module for Android and iOS. You can continue to the next tutorial to learn how to create a native view.