You could think of AsyncStorage as local storage for React Native. That’s because it is! As described on React Native’s website: “AsyncStorage is an unencrypted, asynchronous, persistent, key-value storage system that is global to the app.”
It’s a mouthful. But simply put, it allows you to save data locally on the user’s device. Say you want to recall a users theme setting or allow them to pick up where they left off after restarting their phone or the app, being able to persist data offline makes AsyncStorage your best friend!
If, on the other hand, you need to store sensitive data — i.e., a JWT token — AsyncStorage is that best friend who always gets you in trouble. Remember, the data that you save with AsyncStorage is unencrypted, so anyone with access can read it, which isn’t good for sensitive data.
For sensitive information, we need an encrypted and secure way of storing data locally. Luckily, we have options:
All three of these are great options to use, but in this article, we’re going to cover Expo SecureStore.
Expo is a wonderful SDK with several fabulous libraries, although you need to configure unimodules to use Expo with a Bare React App. But once it’s done, the world is your oyster.
If you would like to have a look the final code, you can find it on my GitHub:
To initialize your project, paste the following into Terminal:
npx react-native init yourAppNameHere --template react-native-template-typescript
This creates a new React Native project with a TypeScript template. But don’t take my word for it, let’s build the apps and see for yourself.
yarn run ios
After some time, you’ll see your shiny new app on your iOS Simulator. Now, for Android:
yarn run android /pre> To use Expo packages in a bare React Native project, we first need to install and configure react-native-unimodules. So in your Terminal, type:
yarn add react-native-unimodules expo-secure-store && cd ios && pod install && cd ..
The above command will install the libraries, navigate to the iOS folder, install your projects’ CocoaPods, then navigate back to your project folder.
💡 Hint: Add a post-install script to your package.json file to save you from manual pod installs:
"scripts": { ... "postinstall": "cd ios && pod install" },
If you’re adding unimodules to an existing project, and, if you’re not familiar with native iOS development, please pay extra attention to where you delete and add new lines of code.
Click here for the full comparison of changes.
First, let’s change the code from this:
#import <React/RCTBridgeDelegate.h> #import <UIKit/UIKit.h> @interface AppDelegate : UIResponder <UIApplicationDelegate, RCTBridgeDelegate> @property (nonatomic, strong) UIWindow *window; @end
To this:
#import <React/RCTBridgeDelegate.h> #import <UIKit/UIKit.h> #import <UMCore/UMAppDelegateWrapper.h> @interface AppDelegate : UMAppDelegateWrapper <UIApplicationDelegate, RCTBridgeDelegate> @property (nonatomic, strong) UIWindow *window; @end
Now, change your code from:
#import "AppDelegate.h" #import <React/RCTBridge.h> #import <React/RCTBundleURLProvider.h> #import <React/RCTRootView.h> #ifdef FB_SONARKIT_ENABLED #import <FlipperKit/FlipperClient.h> #import <FlipperKitLayoutPlugin/FlipperKitLayoutPlugin.h> #import <FlipperKitUserDefaultsPlugin/FKUserDefaultsPlugin.h> #import <FlipperKitNetworkPlugin/FlipperKitNetworkPlugin.h> #import <SKIOSNetworkPlugin/SKIOSNetworkAdapter.h> #import <FlipperKitReactPlugin/FlipperKitReactPlugin.h> static void InitializeFlipper(UIApplication *application) { FlipperClient *client = [FlipperClient sharedClient]; SKDescriptorMapper *layoutDescriptorMapper = [[SKDescriptorMapper alloc] initWithDefaults]; [client addPlugin:[[FlipperKitLayoutPlugin alloc] initWithRootNode:application withDescriptorMapper:layoutDescriptorMapper]]; [client addPlugin:[[FKUserDefaultsPlugin alloc] initWithSuiteName:nil]]; [client addPlugin:[FlipperKitReactPlugin new]]; [client addPlugin:[[FlipperKitNetworkPlugin alloc] initWithNetworkAdapter:[SKIOSNetworkAdapter new]]]; [client start]; } #endif @implementation AppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { #ifdef FB_SONARKIT_ENABLED InitializeFlipper(application); #endif RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions]; RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge moduleName:@"secureStoreExample" initialProperties:nil]; rootView.backgroundColor = [[UIColor alloc] initWithRed:1.0f green:1.0f blue:1.0f alpha:1]; self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; UIViewController *rootViewController = [UIViewController new]; rootViewController.view = rootView; self.window.rootViewController = rootViewController; [self.window makeKeyAndVisible]; return YES; } - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { #if DEBUG return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil]; #else return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; #endif } @end
To this:
#import "AppDelegate.h" #import <React/RCTBridge.h> #import <React/RCTBundleURLProvider.h> #import <React/RCTRootView.h> #import <UMCore/UMModuleRegistry.h> #import <UMReactNativeAdapter/UMNativeModulesProxy.h> #import <UMReactNativeAdapter/UMModuleRegistryAdapter.h> #ifdef FB_SONARKIT_ENABLED #import <FlipperKit/FlipperClient.h> #import <FlipperKitLayoutPlugin/FlipperKitLayoutPlugin.h> #import <FlipperKitUserDefaultsPlugin/FKUserDefaultsPlugin.h> #import <FlipperKitNetworkPlugin/FlipperKitNetworkPlugin.h> #import <SKIOSNetworkPlugin/SKIOSNetworkAdapter.h> #import <FlipperKitReactPlugin/FlipperKitReactPlugin.h> static void InitializeFlipper(UIApplication *application) { FlipperClient *client = [FlipperClient sharedClient]; SKDescriptorMapper *layoutDescriptorMapper = [[SKDescriptorMapper alloc] initWithDefaults]; [client addPlugin:[[FlipperKitLayoutPlugin alloc] initWithRootNode:application withDescriptorMapper:layoutDescriptorMapper]]; [client addPlugin:[[FKUserDefaultsPlugin alloc] initWithSuiteName:nil]]; [client addPlugin:[FlipperKitReactPlugin new]]; [client addPlugin:[[FlipperKitNetworkPlugin alloc] initWithNetworkAdapter:[SKIOSNetworkAdapter new]]]; [client start]; } #endif @interface AppDelegate () <RCTBridgeDelegate> @property (nonatomic, strong) UMModuleRegistryAdapter *moduleRegistryAdapter; @end @implementation AppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { #ifdef FB_SONARKIT_ENABLED InitializeFlipper(application); #endif self.moduleRegistryAdapter = [[UMModuleRegistryAdapter alloc] initWithModuleRegistryProvider:[[UMModuleRegistryProvider alloc] init]]; RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions]; RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge moduleName:@"secureStoreExample" initialProperties:nil]; rootView.backgroundColor = [[UIColor alloc] initWithRed:1.0f green:1.0f blue:1.0f alpha:1]; self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; UIViewController *rootViewController = [UIViewController new]; rootViewController.view = rootView; self.window.rootViewController = rootViewController; [self.window makeKeyAndVisible]; [super application:application didFinishLaunchingWithOptions:launchOptions]; return YES; } - (NSArray<id<RCTBridgeModule>> *)extraModulesForBridge:(RCTBridge *)bridge { NSArray<id<RCTBridgeModule>> *extraModules = [_moduleRegistryAdapter extraModulesForBridge:bridge]; // If you'd like to export some custom RCTBridgeModules that are not Expo modules, add them here! return extraModules; } - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { #if DEBUG return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil]; #else return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; #endif } @end
Next, we’ll take this code:
require_relative '../node_modules/react-native/scripts/react_native_pods' require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules' platform :ios, '10.0' target 'secureStoreExample' do config = use_native_modules! use_react_native!(:path => config["reactNativePath"]) target 'secureStoreExampleTests' do inherit! :complete # Pods for testing end # Enables Flipper. # # Note that if you have use_frameworks! enabled, Flipper will not work and # you should disable these next few lines. use_flipper! post_install do |installer| flipper_post_install(installer) end end target 'secureStoreExample-tvOS' do # Pods for secureStoreExample-tvOS target 'secureStoreExample-tvOSTests' do inherit! :search_paths # Pods for testing end end
And change it to:
require_relative '../node_modules/react-native/scripts/react_native_pods' require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules' require_relative '../node_modules/react-native-unimodules/cocoapods.rb' platform :ios, '10.0' target 'secureStoreExample' do config = use_native_modules! use_unimodules! use_react_native!(:path => config["reactNativePath"]) target 'secureStoreExampleTests' do inherit! :complete # Pods for testing end # Enables Flipper. # # Note that if you have use_frameworks! enabled, Flipper will not work and # you should disable these next few lines. use_flipper! post_install do |installer| flipper_post_install(installer) end end target 'secureStoreExample-tvOS' do # Pods for secureStoreExample-tvOS target 'secureStoreExample-tvOSTests' do inherit! :search_paths # Pods for testing end end
Now that we’ve made the necessary changes to the iOS folder, we need to install our CocoaPods again:
cd ios && pod install
At the time of writing, the most recent version of Expo SecureStore wasn’t compatible with CocoaPods. If you run into this issue, install the previous version. In my case:
yarn add [email protected] && cd ios && pod install && cd ..
Click here for the full comparison of changes.
Let’s update the code from the following.
// Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { ext { buildToolsVersion = "29.0.2" minSdkVersion = 16 compileSdkVersion = 29 targetSdkVersion = 29 } repositories { google() jcenter() } dependencies { classpath("com.android.tools.build:gradle:3.5.3") // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } } allprojects { repositories { mavenLocal() maven { // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm url("$rootDir/../node_modules/react-native/android") } maven { // Android JSC is installed from npm url("$rootDir/../node_modules/jsc-android/dist") } google() jcenter() maven { url 'https://www.jitpack.io' } } }
And change it to:
// Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { ext { buildToolsVersion = "29.0.2" minSdkVersion = 21 compileSdkVersion = 29 targetSdkVersion = 29 } repositories { google() jcenter() } dependencies { classpath("com.android.tools.build:gradle:3.5.3") // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } } allprojects { repositories { mavenLocal() maven { // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm url("$rootDir/../node_modules/react-native/android") } maven { // Android JSC is installed from npm url("$rootDir/../node_modules/jsc-android/dist") } google() jcenter() maven { url 'https://www.jitpack.io' } } }
From this:
apply plugin: "com.android.application" import com.android.build.OutputFile .... //roughly line 183 dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:+" // From node_modules implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0" debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") { exclude group:'com.facebook.fbjni' } debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") { exclude group:'com.facebook.flipper' exclude group:'com.squareup.okhttp3', module:'okhttp' } debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") { exclude group:'com.facebook.flipper' } if (enableHermes) { def hermesPath = "../../node_modules/hermes-engine/android/"; debugImplementation files(hermesPath + "hermes-debug.aar") releaseImplementation files(hermesPath + "hermes-release.aar") } else { implementation jscFlavor } }
To this:
apply plugin: "com.android.application" apply from: '../../node_modules/react-native-unimodules/gradle.groovy' import com.android.build.OutputFile ... //roughly line 184: dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:+" // From node_modules implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0" // Add this line here addUnimodulesDependencies() addUnimodulesDependencies() debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") { exclude group:'com.facebook.fbjni' } debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") { exclude group:'com.facebook.flipper' exclude group:'com.squareup.okhttp3', module:'okhttp' } debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") { exclude group:'com.facebook.flipper' } if (enableHermes) { def hermesPath = "../../node_modules/hermes-engine/android/"; debugImplementation files(hermesPath + "hermes-debug.aar") releaseImplementation files(hermesPath + "hermes-release.aar") } else { implementation jscFlavor } }
Now, let’s go from:
rootProject.name = 'secureStoreExample' apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings) include ':app'
To this:
rootProject.name = 'secureStoreExample' apply from: '../node_modules/react-native-unimodules/gradle.groovy'; includeUnimodulesProjects() apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings) include ':app'
Change your code from:
package com.securestoreexample; import android.app.Application; import android.content.Context; import com.facebook.react.PackageList; import com.facebook.react.ReactApplication; import com.facebook.react.ReactInstanceManager; import com.facebook.react.ReactNativeHost; import com.facebook.react.ReactPackage; import com.facebook.soloader.SoLoader; import java.lang.reflect.InvocationTargetException; import java.util.List; public class MainApplication extends Application implements ReactApplication { private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) { @Override public boolean getUseDeveloperSupport() { return BuildConfig.DEBUG; } @Override protected List<ReactPackage> getPackages() { @SuppressWarnings("UnnecessaryLocalVariable") List<ReactPackage> packages = new PackageList(this).getPackages(); // Packages that cannot be autolinked yet can be added manually here, for example: // packages.add(new MyReactNativePackage()); return packages; } @Override protected String getJSMainModuleName() { return "index"; } }; ...
To:
package com.securestoreexample; import com.secureStoreExample.generated.BasePackageList; import android.app.Application; import android.content.Context; import com.facebook.react.PackageList; import com.facebook.react.ReactApplication; import com.facebook.react.ReactInstanceManager; import com.facebook.react.ReactNativeHost; import com.facebook.react.ReactPackage; import com.facebook.soloader.SoLoader; import java.lang.reflect.InvocationTargetException; import java.util.List; import java.util.Arrays; import org.unimodules.adapters.react.ModuleRegistryAdapter; import org.unimodules.adapters.react.ReactModuleRegistryProvider; import org.unimodules.core.interfaces.SingletonModule; public class MainApplication extends Application implements ReactApplication { private final ReactModuleRegistryProvider mModuleRegistryProvider = new ReactModuleRegistryProvider(new BasePackageList().getPackageList(), null); private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) { @Override public boolean getUseDeveloperSupport() { return BuildConfig.DEBUG; } @Override protected List<ReactPackage> getPackages() { @SuppressWarnings("UnnecessaryLocalVariable") List<ReactPackage> packages = new PackageList(this).getPackages(); // Packages that cannot be autolinked yet can be added manually here, for example: // packages.add(new MyReactNativePackage()); // Add unimodules List<ReactPackage> unimodules = Arrays.<ReactPackage>asList( new ModuleRegistryAdapter(mModuleRegistryProvider) ); packages.addAll(unimodules); return packages; } @Override protected String getJSMainModuleName() { return "index"; } }; ...
SecureStore has three main API methods: set, get, and delete. Let’s clear out our App.tsx file and explore them. To begin, replace the contents of App.tsx with:
import React, {useState} from 'react'; import {SafeAreaView, StyleSheet, StatusBar, Button, Text} from 'react-native'; const App = () => { return ( <> <StatusBar barStyle="dark-content" /> <SafeAreaView style={styles.container}> <Button title="set token" onPress={() => {}} /> <Button title="get token" onPress={() => {}} /> <Button title="delete token" onPress={() => {}} /> <Text>Token will appear here</Text> </SafeAreaView> </> ); }; const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', }, }); export default App;
This gives us three buttons in the middle of the screen. They don’t do anything at the moment, so let’s fix that! Like AsyncStorage, SecureStore uses key-value pairs, and as we’re using TypeScript we can utilize enums:
import React, {useState} from 'react'; import {SafeAreaView, StyleSheet, StatusBar, Button, Text} from 'react-native'; export enum SecureStoreEnum { TOKEN = 'token', } const App = () => { return ( <> <StatusBar barStyle="dark-content" /> <SafeAreaView style={styles.container}> <Button title="set token" onPress={() => {}} /> <Button title="get token" onPress={() => {}} /> <Button title="delete token" onPress={() => {}} /> <Text>{token}</Text> </SafeAreaView> </> ); }; const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', }, }); export default App;
We use this enum (key) to pair the value that we’re going to store on our device.
Next, we’ll configure a useState hook to store our state variable — value retrieved from our device — plus the mock variable that we will be storing.
import React, {useState} from 'react'; import {SafeAreaView, StyleSheet, StatusBar, Button, Text} from 'react-native'; export enum SecureStoreEnum { TOKEN = 'token', } const App = () => { const [token, setToken] = useState<string>(''); const fakeToken = 'A fake token 🍪'; return ( <> <StatusBar barStyle="dark-content" /> <SafeAreaView style={styles.container}> <Button title="set token" onPress={() => {}} /> <Button title="get token" onPress={() => {}} /> <Button title="delete token" onPress={() => {}} /> <Text>{token}</Text> </SafeAreaView> </> ); }; const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', }, }); export default App;
You can view the code in this gist.
Okay, everything’s set up, now we’re really cooking with gas! Time to play with SecureStore. 😃
Because SecureStore is asynchronous, we need to call it in an async function. Add the following to App.tsx:
import React, {useState} from 'react'; import { SafeAreaView, StyleSheet, StatusBar, Button, Text, Alert, } from 'react-native'; import * as SecureStore from 'expo-secure-store'; export enum SecureStoreEnum { TOKEN = 'token', } const App = () => { const [token, setToken] = useState<string>(''); const fakeToken = 'A fake token 🍪'; const handleSetToken = async () => { SecureStore.setItemAsync(SecureStoreEnum.TOKEN, fakeToken).then; setToken(fakeToken); }; return ( <> <StatusBar barStyle="dark-content" /> <SafeAreaView style={styles.container}> <Button title="set token" onPress={handleSetToken} /> <Button title="get token" onPress={() => {}} /> <Button title="delete token" onPress={() => {}} /> <Text>{token}</Text> </SafeAreaView> </> ); }; const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', }, }); export default App;
setItemAsync
uses the enum that we created earlier as the key to the value that we’re saving, A fake token 🍪
. It then waits for the function to complete. Once our asynchronous function is complete, we save the same variable to our useState hook with setToken
.
Now, if you click set token, you’ll see our fake token appear below the three buttons.
Next, we will give our “get token” button some functionality so we can retrieve the value that we’ve stored on our device. Copy and paste this into App.tsx:
import React, {useState} from 'react'; import { SafeAreaView, StyleSheet, StatusBar, Button, Text, Alert, } from 'react-native'; import * as SecureStore from 'expo-secure-store'; export enum SecureStoreEnum { TOKEN = 'token', } const App = () => { const [token, setToken] = useState<string>(''); const fakeToken = 'A fake token 🍪'; const handleSetToken = async () => { await SecureStore.setItemAsync(SecureStoreEnum.TOKEN, fakeToken); setToken(fakeToken); }; const handleGetToken = async () => { const tokenFromPersistentState = await SecureStore.getItemAsync( SecureStoreEnum.TOKEN, ); if (tokenFromPersistentState) { Alert.alert( "This token is stored on your device, isn't that cool!:", tokenFromPersistentState, ); } }; return ( <> <StatusBar barStyle="dark-content" /> <SafeAreaView style={styles.container}> <Button title="set token" onPress={handleSetToken} /> <Button title="get token" onPress={handleGetToken} /> <Button title="delete token" onPress={() => {}} /> <Text>{token}</Text> </SafeAreaView> </> ); }; const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', }, }); export default App;
We use an if statement on line 29 to ensure that the alert box will only open if a value is retrieved.
Now for the magic: close your app or restart your device and click on get token. Ta-da! Your token is back.
If this were a real-world application, we’d need to be able to delete the users token when they log out. Last piece of code, I promise 😇.
import React, {useState} from 'react'; import { SafeAreaView, StyleSheet, StatusBar, Button, Text, Alert, } from 'react-native'; import * as SecureStore from 'expo-secure-store'; export enum SecureStoreEnum { TOKEN = 'token', } const App = () => { const [token, setToken] = useState<string>(''); const fakeToken = 'A fake token 🍪'; const handleSetToken = async () => { await SecureStore.setItemAsync(SecureStoreEnum.TOKEN, fakeToken); setToken(fakeToken); }; const handleGetToken = async () => { const tokenFromPersistentState = await SecureStore.getItemAsync( SecureStoreEnum.TOKEN, ); if (tokenFromPersistentState) { Alert.alert( "This token is stored on your device, isn't that cool!:", tokenFromPersistentState, ); } }; const handleDeleteToken = async () => { await SecureStore.deleteItemAsync(SecureStoreEnum.TOKEN); setToken(''); }; return ( <> <StatusBar barStyle="dark-content" /> <SafeAreaView style={styles.container}> <Button title="set token" onPress={handleSetToken} /> <Button title="get token" onPress={handleGetToken} /> <Button title="delete token" onPress={handleDeleteToken} /> <Text>{token}</Text> </SafeAreaView> </> ); }; const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', }, }); export default App;
Here, handleDeleteToken
on line 37 deletes the token then sets the token state to an empty string (initial state).
Now your data’s secure and encrypted!
LogRocket is a React Native monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your React Native apps.
LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.
Start proactively monitoring your React Native apps — try LogRocket for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]