Refreshing Routes WITHOUT Global State in React

Clickbait? No. I mean it. Seriously.

I just discovered this amazing React Navigation API called useFocusEffect that essentially runs an useEffect when a route comes into focus. I know you might be wondering why I am advocating for “just another” useEffect at this point. Bear with me a bit.

My (note that word kindly) normal workflow for dealing with screen updates as I’ll call them has been using a global state manager like Zustand to trigger refreshes. At first I started out by storing whatever I wanted in state directly from the database, just like you do normally with a React useState hook. The only difference is this is global which allows you to use that data across different components and screens.

For example:

// lib/State.ts

import { create } from 'zustand';
import { Article } from 'types';

const StateStore = create((set) => ({ 
    allArticles: 0,
    setAllArticles: (articles: Article[]) => set(({ allArticles: refreshArticlesToken + 1 })),
});

export default StateStore;
// (tabs)/articles/index.tsx

import Button from "@/components/shared/Button";
import CustomModal from "@/components/shared/CustomModal";
import { Dialog } from "@/components/shared/Dialog";
import EmptyPagePlaceholder from "@/components/shared/EmptyPagePlaceholder";
import FAB from "@/components/shared/FAB";
import Loading from "@/components/shared/Loading";
import Colours from "@/lib/Colours";
import { useMyAppContext } from "@/lib/Context";
import { useDatabase } from "@/lib/Database/Provider";
import { deleteArticle, retrieveAllArticles } from "@/lib/Database/Operations/Articles";
import StateStore from "@/lib/State";
import { DatabaseArticle } from "@/types/articles";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { FlatList, Text, TextInput, ToastAndroid, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

export default function ArticlesScreen() {
    const { customTheme } = useMyAppContext();
    const { database } = useDatabase();
    const router = useRouter();

    const [showAddModal, setShowAddModal] = useState(false);
    const [articleUrl, setArticleUrl] = useState('');
    const setExtractedArticle = StateStore(state => state.setExtractedArticle);
    const allArticles= StateStore(state => state.allArticles);
    const setAllArticles= StateStore(state => state.setAllArticles);
    
    const [deleteOperation, setDeleteOperation] = useState(false);
    const [articleToDelete, setArticleToDelete] = useState(null);
    const [toggledArticleOptions, setToggledArticleOptions] = useState(null);

    useEffect(() => {
        (async () => {
            const { success, message, response } = await retrieveAllArticles(database);
            if (!success) {
                ToastAndroid.show(message, ToastAndroid.LONG);
            }

            const newArticles = response ? [...response] : null;
            setArticles(newArticles);
        })(); 
    }, [refreshArticlesToken]); 

    const handleDelete = async () => {
        if (!articleToDelete) {
            ToastAndroid.show("Missing article ID", ToastAndroid.LONG);
            return;
        };

        const { message } = await deleteArticle(database, articleToDelete);
        ToastAndroid.show(message, ToastAndroid.LONG);
        setToggledArticleOptions(null);
        setArticleToDelete(null);
        toggleRefreshArticlesToken();
    }

    if (!articles) {
        return 
    }

    return (
        
            {articles?.length > 0
                ? (
                     item.id}
                        renderItem={({item}) => (
                             
                                    router.navigate({
                                        pathname: '/(tabs)/articles/[articleID]',
                                        params: { articleID: item.id }
                                    })
                                } 
                                toggledArticleOptions={toggledArticleOptions}
                                setToggledArticleOptions={setToggledArticleOptions}
                                setDeleteOperation={setDeleteOperation}
                                setArticleToDelete={setArticleToDelete}
                            />
                        )}
                        contentContainerStyle={{ padding: 12 }}
                        ItemSeparatorComponent={() => }
                    />
                ) 
                : ()
            }

             {
                    setShowAddModal(prev => !prev);
                    setArticleUrl('');
                }}
                style={{ gap: '5%' }}
            >
                {/* More app logic */}
                
								{/* Extracting the article on an External Server and then saving it in the global zustand state */}
                
            
            

             setDeleteOperation(false)}
                title="Delete article"
                actions={
                    <>
                        
                        
                    
                }
            >
                Are you sure you want to delete {articleToDelete}?
            

             setShowAddModal(true)} />
        
    );
}
// (tabs)/articles/add.tsx

import Button from "@/components/shared/Button";
import Loading from "@/components/shared/Loading";
import Colours from "@/lib/Colours";
import { useMyAppContext } from "@/lib/Context";
import { useDatabase } from "@/lib/Database/Provider";
import { newArticle } from "@/lib/Database/Operations/Articles";
import StateStore from "@/lib/State";
import { useRouter } from "expo-router";
import { ScrollView, Text, ToastAndroid, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

export default function ArticleAddScreen() {
    const { customTheme } = useMyAppContext();
    const { database } = useDatabase();
    const router = useRouter();

    const extractedArticle = StateStore(state => state.extractedArticle);
    const allArticles = StateStore(state => state.allArticles);
    const setAllArticles = StateStore(state => state.setAllArticles);
    
    return (
        
            {extractedArticle 
                ? (
                    
                        {/* Normal Screen Logic */}

                        
                            

														{/* The Focus is here on the adding of a new Article */}
                            
                        
                    
                ) 
                : ()
            }
        
    )
}

You get my point hopefully. I have to duplicate the database snapshot in global state just to handle the article addition and deletion operations that require a screen refresh, which introduces again the problem of ensuring that I sync the data in both the database and state. A lot of work at that, which is very error prone.

So I decided to look up ways of eliminating the need for the global state duplicate altogether and rely on the local SQLite Database instead. The problem would be knowing when to react to database updates since the database itself is not reactive. After a long search I bumped into the use of the useFocusEffect React Navigation Hook on StackOverflow.

The hook is simple; trigger a useEffect when the route is focused. So for the route mounting it essentially works the same as a useEffect. But let’s say the route loses focus and then you navigate back to it, the useEffect hook would only re-run if you have a variable that you track in the dependency array, in which case is would be the allArticles global state. Yes it works but you need the articles duplicated in state for it to work.

Enter useFocusEffect which re-runs whenever the router is focused. Whether previously mounted or not, it only checks for the focus of the route. So the example I gave above changes as follows:

// (tabs)/articles/add.tsx

import Button from "@/components/shared/Button";
import Loading from "@/components/shared/Loading";
import Colours from "@/lib/Colours";
import { useMyAppContext } from "@/lib/Context";
import { useDatabase } from "@/lib/Database/Provider";
import { newArticle } from "@/lib/Database/Operations/Articles";
import StateStore from "@/lib/State";
import { useRouter } from "expo-router";
import { ScrollView, Text, ToastAndroid, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

export default function ArticleAddScreen() {
    const { customTheme } = useMyAppContext();
    const { database } = useDatabase();
    const router = useRouter();

    const extractedArticle = StateStore(state => state.extractedArticle);
    
    return (
        
            {extractedArticle 
                ? (
                    
                        {/* Normal Screen Logic */}

                        
                            

														{/* The Focus is here on the adding of a new Article */}
                            
                        
                    
                ) 
                : ()
            }
        
    )
}
// (tabs)/articles/index.tsx

import Button from "@/components/shared/Button";
import CustomModal from "@/components/shared/CustomModal";
import { Dialog } from "@/components/shared/Dialog";
import EmptyPagePlaceholder from "@/components/shared/EmptyPagePlaceholder";
import FAB from "@/components/shared/FAB";
import Loading from "@/components/shared/Loading";
import Colours from "@/lib/Colours";
import { useMyAppContext } from "@/lib/Context";
import { useDatabase } from "@/lib/Database/Provider";
import { deleteArticle, retrieveAllArticles } from "@/lib/Database/Operations/Articles";
import StateStore from "@/lib/State";
import { DatabaseArticle } from "@/types/articles";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useFocusEffect, useRouter } from "expo-router";
import { useCallback, useState } from "react";
import { FlatList, Text, TextInput, ToastAndroid, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

export default function ArticlesScreen() {
    const { customTheme } = useMyAppContext();
    const { database } = useDatabase();
    const router = useRouter();

    const [showAddModal, setShowAddModal] = useState(false);
    const [articleUrl, setArticleUrl] = useState('');
    const setExtractedArticle = StateStore(state => state.setExtractedArticle);
    
    // Local useState that holds the articles from the database
    // No need for duplicated global zustand state to hold the articles from the database
    const [articles, setArticles] = useState(null);
    
    const [deleteOperation, setDeleteOperation] = useState(false);
    const [articleToDelete, setArticleToDelete] = useState(null);
    const [toggledArticleOptions, setToggledArticleOptions] = useState(null);

    const fetchArticles = async () => {
        const { success, message, response } = await retrieveAllArticles(database);
        if (!success) {
            ToastAndroid.show(message, ToastAndroid.LONG);
        }

        const newArticles = response ? [...response] : null;
        
	       // Always updates the local state held in the `useState` hook
        setArticles(newArticles);
    };

		// This replaces the `useEffect` hook
		// Triggered whenever the route comes into focus
    useFocusEffect(
        useCallback(()=> {
            fetchArticles();
        }, [])
    );

    const handleDelete = async () => {
        if (!articleToDelete) {
            ToastAndroid.show("Missing article ID", ToastAndroid.LONG);
            return;
        };

        const { message } = await deleteArticle(database, articleToDelete);
        ToastAndroid.show(message, ToastAndroid.LONG);
        setToggledArticleOptions(null);
        setArticleToDelete(null);
        
        // After deleting the article, use the database function call to refetch the articles
        // A refresh is triggered since the local state gets updated within this function call
        // Effectively eliminates the need to duplicate the articles in global Zustand state
        xfetchArticles();
    }

    if (!articles) {
        return 
    }

    return (
        
            {articles?.length > 0
                ? (
                     item.id}
                        renderItem={({item}) => (
                             
                                    router.navigate({
                                        pathname: '/(tabs)/articles/[articleID]',
                                        params: { articleID: item.id }
                                    })
                                } 
                                toggledArticleOptions={toggledArticleOptions}
                                setToggledArticleOptions={setToggledArticleOptions}
                                setDeleteOperation={setDeleteOperation}
                                setArticleToDelete={setArticleToDelete}
                            />
                        )}
                        contentContainerStyle={{ padding: 12 }}
                        ItemSeparatorComponent={() => }
                    />
                ) 
                : ()
            }

             {
                    setShowAddModal(prev => !prev);
                    setArticleUrl('');
                }}
                style={{ gap: '5%' }}
            >
                {/* More app logic */}
                
								{/* Extracting the article on an External Server and then saving it in the global zustand state */}
                
            
            

             setDeleteOperation(false)}
                title="Delete article"
                actions={
                    <>
                        
                        
                    
                }
            >
                Are you sure you want to delete {articleToDelete}?
            

             setShowAddModal(true)} />
        
    );
}

And just like that, there is no need to duplicate the database snapshot in global state just to trigger refreshes.

NB: My useFocusEffect import is from Expo Router but that does not mean you need expo router for the hook. Expo just builds on top of the React Navigation one.