Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

React Native Flatlist search returning no values from firebase users collection

so I recently tried to make a FlatList search for users in firebase, but I have been running into a bunch of errors, although there seems to be no bugs in the code. At the moment, the list searches and does not return anything, although there is clearly data in the firebase collection of "users". When I try to log "results" right above the resolve statement of the Promise in getUsers(), I all of a sudden see users, although I get the error that "results" does not exist, which is strange because why does the error make the code work? Anyways, If anyone would be able to help me in trying to make this FlatList work, I would greatly appreciate it. I have been working on this for 3 days now and can't seem to find any solution online or fix the code. For your help, I would gladly venmo you a dunkin donut, as this means a lot to me. I appreciate all help and tips, and thank you in advance for your time! (The code for my flatlist is below without the styles)

import React, { useState, useContext, useEffect } from "react";
import {
    View,
    Text,
    StyleSheet,
    StatusBar,
    TextInput,
    ScrollView,
    Image,
    ActivityIndicator,
    TouchableOpacity,
    FlatList,
} from "react-native";
import { FirebaseContext } from "../context/FirebaseContext";
import { UserContext } from "../context/UserContext";
import { FontAwesome5, Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import _ from "lodash";
import "firebase/firestore";
import firebase from "firebase";
import config from "../config/firebase";

const SearchScreen = ({ navigation }) => {
    const [searchText, setSearchText] = useState("");
    const [loading, setLoading] = useState(true);
    const [data, setData] = useState([]);
    const [refreshing, setRefreshing] = useState(false);
    const [query, setQuery] = useState("");
    const [userNumLoad, setUserNumLoad] = useState(20);
    const [error, setError] = useState("");

    useEffect(() => {
        const func = async () => {
            await makeRemoteRequest();
        };
        func();
    }, []);

    const contains = (user, query) => {
        if (user.username.includes(query)) {
            return true;
        }
        return false;
    };

    const getUsers = async (limit = 20, query2 = "") => {
        var list = [];
        await firebase
            .firestore()
            .collection("users")
            .get()
            .then((querySnapshot) => {
                querySnapshot.forEach((doc) => {
                    if (doc.data().username.includes(query2)) {
                        list.push({
                            profilePhotoUrl: doc.data().profilePhotoUrl,
                            username: doc.data().username,
                            friends: doc.data().friends.length,
                            uid: doc.data().uid,
                        });
                    }
                });
            });

        setTimeout(() => {
            setData(list);
        }, 4000);


        return new Promise(async (res, rej) => {
            if (query.length === 0) {
                setTimeout(() => {
                    res(_.take(data, limit));
                }, 8000);

            } else {
                const formattedQuery = query.toLowerCase();
                const results = _.filter(data, (user) => {
                    return contains(user, formattedQuery);
                });
                setTimeout(() => {
                    res(_.take(results, limit));
                }, 8000);

            }
        });
    };

    const makeRemoteRequest = _.debounce(async () => {
        const users = [];
        setLoading(true);
        await getUsers(userNumLoad, query)
            .then((users) => {
                setLoading(false);
                setData(users);
                setRefreshing(false);
            })
            .catch((err) => {
                setRefreshing(false);
                setError(err);
                setLoading(false);
                //alert("An error has occured. Please try again later.");
                console.log(err);
            });
    }, 250);

    const handleSearch = async (text) => {
        setSearchText(text);
        const formatQuery = text.toLowerCase();
        await setQuery(text.toLowerCase());
        const data2 = _.filter(data, (user) => {
            return contains(user, formatQuery);
        });
        setData(data2);
        await makeRemoteRequest();
    };

    const handleRefresh = async () => {
        setRefreshing(true);
        await makeRemoteRequest();
    };

    const handleLoadMore = async () => {
        setUserNumLoad(userNumLoad + 20);
        await makeRemoteRequest();
    };

    const renderFooter = () => {
        if (!loading) return null;

        return (
            <View style={{ paddingVertical: 20 }}>
                <ActivityIndicator animating size="large" />
            </View>
        );
    };

    return (
        <View style={styles.container}>
            <View style={styles.header}>
                <TouchableOpacity
                    style={styles.goBackButton}
                    onPress={() => navigation.goBack()}
                >
                    <LinearGradient
                        colors={["#FF5151", "#ac46de"]}
                        style={styles.backButtonGradient}
                    >
                        <Ionicons name="arrow-back" size={30} color="white" />
                    </LinearGradient>
                </TouchableOpacity>
                <View style={styles.spacer} />
                <Text style={styles.headerText}>Search</Text>
                <View style={styles.spacer} />
                <View style={{ width: 46, marginLeft: 15 }}></View>
            </View>
            <View style={styles.inputView}>
                <FontAwesome5 name="search" size={25} color="#FF5151" />
                <TextInput
                    style={styles.input}
                    label="Search"
                    value={searchText}
                    onChangeText={(newSearchText) => handleSearch(newSearchText)}
                    placeholder="Search for people"
                    autoCapitalize="none"
                    autoCorrect={false}
                />
            </View>

            <FlatList
                style={styles.list}
                data={data}
                renderItem={({ item }) => (
                    <TouchableOpacity>
                        <View style={styles.listItem}>
                            <Image
                                style={styles.profilePhoto}
                                source={
                                    item.profilePhotoUrl === "default"
                                        ? require("../../assets/defaultProfilePhoto.jpg")
                                        : { uri: item.profilePhotoUrl }
                                }
                            />
                            <View style={styles.textBody}>
                                <Text style={styles.username}>{item.username}</Text>
                                <Text style={styles.subText}>{item.friends} Friends</Text>
                            </View>
                        </View>
                    </TouchableOpacity>
                )}
                ListFooterComponent={renderFooter}
                keyExtractor={(item) => item.username}
                refreshing={refreshing}
                onEndReachedThreshold={100}
                onEndReached={handleLoadMore}
                onRefresh={handleRefresh}
            />
        </View>
    );
};

const styles = StyleSheet.create({
    container: {
        flex: 1
    },
    searchbar: {
        backgroundColor: 'white'
    },
    header: {
        height: 70,
        flexDirection: 'row',
        justifyContent: 'space-between',
        marginTop: 60,
        paddingLeft: 10,
        paddingRight: 10
    },
    goBackButton: {
        width: 46,
        height: 46,
        borderRadius: 23,
        marginBottom: 10,
        marginLeft: 15
    },
    backButtonGradient: {
        borderRadius: 23,
        height: 46,
        width: 46,
        justifyContent: 'center',
        alignItems: 'center'
    },
    settingsButton: {
        width: 46,
        height: 46,
        borderRadius: 23,
        marginRight: 15,
        marginBottom: 10
    },
    settingsButtonGradient: {
        borderRadius: 23,
        height: 46,
        width: 46,
        justifyContent: 'center',
        alignItems: 'center'
    },
    input: {
        height: 45,
        width: 250,
        paddingLeft: 10,
        fontFamily: "Avenir",
        fontSize: 18
    },
    inputView: {
        flexDirection: 'row',
        justifyContent: 'center',
        alignItems: 'center',
        borderRadius: 50,
        paddingLeft: 10,
        paddingRight: 20,
        shadowColor: 'gray',
        shadowOffset: {width: 5, height: 8},
        shadowOpacity: 0.1,
        backgroundColor: "#ffffff",
        marginRight: 28,
        marginLeft: 28,
        marginTop: 10,
        marginBottom: 25
    },
    headerText: {
        fontSize: 35,
        fontWeight: "800",
        fontFamily: "Avenir",
        color: "#FF5151",
    },
    spacer: {
        width: 50
    },
    listItem: {
        flexDirection: 'row',
        paddingLeft: 15,
        paddingRight: 15,
        paddingTop: 10,
        paddingBottom: 10,
        backgroundColor: "white",
        marginLeft: 20,
        marginRight: 20,
        marginBottom: 10,
        borderRadius: 15,
        alignItems: 'center',
        shadowOpacity: 0.05,
        shadowRadius: 2,
        shadowOffset: {width: 3, height: 3}
    },
    line: {
        width: 100,
        color: 'black',
        height: 1
    },
    profilePhoto: {
        height: 50,
        width: 50,
        borderRadius: 25
    },
    username: {
        fontSize: 18,
        fontFamily: "Avenir",
        paddingBottom: 3
    },
    subText: {
        fontSize: 15,
        fontFamily: "Avenir"
    },
    textBody: {
        flex: 1,
        justifyContent: 'center',
        marginLeft: 20
    }
});

export default SearchScreen;
like image 762
CSStudent123456 Avatar asked Apr 16 '21 17:04

CSStudent123456


2 Answers

Here are some observations about your current code.

Wasteful querying of /users

you query all user documents in the collection /users regardless of whether you need them or not. For a small number of users, this is fine. But as you scale your application to hundreds if not thousands of users, this will quickly become an expensive endeavour.

Instead of reading complete documents to only check a username, it is advantageous to query only the data you need. A more efficient way to achieve this over using Firstore is to create a username index in the Realtime Database (projects can use both the RTDB and Firestore at the same time).

Let's say you had the following index:

{
    "usernames": {
        "comegrabfood": "NbTmTrMBN3by4LffctDb03K1sXA2",
        "omegakappas": "zpYzyxSriOMbv4MtlMVn5pUbRaD2",
        "somegal": "SLSjzMLBkBRaccXIhwDOn6nhSqk2",
        "thatguy": "by6fl3R2pCPITXPz8L2tI3IzW223",
        ...
    }
}

which you can build from your users collection (with appropriate permissions and a small enough list of users) using the one off command:

// don't code this in your app, just run from it in a browser window while logged in
// once set up, maintain it while creating/updating usernames
const usersFSRef = firebase.firestore().collection("users");
const usernamesRTRef = firebase.database().ref("usernames");

const usernameUIDMap = {};

usersFSRef.get().then((querySnapshot) => {
    querySnapshot.forEach((userDoc) => {
        usernameUIDMap[userDoc.get("username")] = userDoc.get("uid");
    });
});

usernamesRTRef.set(usernameUIDMap)
  .then(
    () => console.log("Index created successfully"),
    (err) => console.error("Failed to create index")
  );

When no search text is provided, the FlatList should contain the first 20 usernames sorted lexicographically. For the index above, this would give "comegrabfood", "omegakappas", "somegal", "thatguy", and so on in that order. When a user searches usernames containing the text "ome", we want the username "omegakappas" to appear first in the FlatList because it starts with the search string, but we want "comegrabfood" and "somegal" in the results too. If there were at least 20 names that begin with "ome", they should be what appear in the FlatList rather than entries that don't start with the search string.

Based on that, we have the following requirements:

  • If no search string is provided, return the user data corresponding to the first usernames up to the given limit.
  • If a search string is provided, return as many entries that start with that string, up to the given limit, if there are remaining slots, find entries that contain "ome" anywhere in the string.

The code form of this is:

// above "const SearchScreen = ..."
const searchUsernames = async (limit = 20, containsString = "") => {
    const usernamesRTRef = firebase.database().ref("usernames");
    const usernameIdPairs = [];

    // if not filtering by a string, just pull the first X usernames sorted lexicographically.
    if (!containsString) {
        const unfilteredUsernameMapSnapshot = await usernamesRTRef.limitToFirst(limit).once('value');
        
        unfilteredUsernameMapSnapshot.forEach((entrySnapshot) => {
            const username = entrySnapshot.key;
            const uid = entrySnapshot.val();
            usernameIdPairs.push({ username, uid });
        });

        return usernameIdPairs;
    }

    // filtering by string, prioritize usernames that start with that string
    const priorityUsernames = {}; // "username" -> true (for deduplication)
    const lowerContainsString = containsString.toLowerCase();
    
    const priorityUsernameMapSnapshot = await usernamesRTRef
        .startAt(lowerContainsString)
        .endAt(lowerContainsString + "/uf8ff")
        .limitToFirst(limit) // only get the first X matching usernames
        .once('value');

    if (priorityUsernameMapSnapshot.hasChildren()) {
        priorityUsernameMapSnapshot.forEach((usernameEntry) => {
            const username = usernameEntry.key;
            const uid = usernameEntry.val();

            priorityUsernames[username] = true;
            usernameIdPairs.push({ username, uid });
        });
    }

    // find out how many more entries are needed
    let remainingCount = limit - usernameIdPairs.length;       

    // set the page size to search
    //   - a small page size will be slow
    //   - a large page size will be wasteful
    const pageSize = 200;

    let lastUsernameOnPage = "";
    while (remainingCount > 0) {
        // fetch up to "pageSize" usernames to scan
        let pageQuery = usernamesRTRef.limitToFirst(pageSize);
        if (lastUsernameOnPage !== "") {
            pageQuery = pageQuery.startAfter(lastUsernameOnPage);
        }
        const fallbackUsernameMapSnapshot = await pageQuery.once('value');

        // no data? break while loop
        if (!fallbackUsernameMapSnapshot.hasChildren()) {
            break;
        }

        // for each username that contains the search string, that wasn't found
        // already above:
        //  - add it to the results array
        //  - decrease the "remainingCount" counter, and if no more results
        //    are needed, break the forEach loop (by returning true)
        fallbackUsernameMapSnapshot.forEach((entrySnapshot) => {
            const username = lastUsernameOnPage = entrySnapshot.key;
            if (username.includes(containsString) && !priorityUsernames[username]) {
                const uid = entrySnapshot.val();
                usernameIdPairs.push({ username, uid });
                // decrease counter and if no entries remain, stop the forEach loop
                return --remainingCount <= 0;
            }
        });
    }

    // return the array of pairs, which will have UP TO "limit" entries in the array
    return usernameIdPairs;
}

Now that we have a list of username-user ID pairs, we need the rest of their user data, which can be fetched using:

// above "const SearchScreen = ..." but below "searchUsernames"
const getUsers = async (limit = 20, containsString = "") => {
    const usernameIdPairs = await searchUsernames(limit, containsString);        

    // compile a list of user IDs, in batches of 10.
    let currentChunk = [], currentChunkLength = 0;
    const chunkedUIDList = [currentChunk];
    for (const pair of usernameIdPairs) {
        if (currentChunkLength === 10) {
            currentChunk = [pair.uid];
            currentChunkLength = 1;
            chunkedUIDList.push(currentChunk);
        } else {
            currentChunk.push(pair.uid);
            currentChunkLength++;
        }
    }

    const uidToDataMap = {}; // uid -> user data
    
    const usersFSRef = firebase.firestore().collection("users");
    
    // fetch each batch of users, adding their data to uidToDataMap
    await Promise.all(chunkedUIDList.map((thisUIDChunk) => (
        usersFSRef
            .where("uid", "in", thisUIDChunk)
            .get()
            .then((querySnapshot) => {
                querySnapshot.forEach(userDataSnapshot => {
                    const uid = userDataSnapshot.id;
                    const docData = userDataSnapshot.data();
                    uidToDataMap[uid] = {
                        profilePhotoUrl: docData.profilePhotoUrl,
                        username: docData.username,
                        friends: docData.friends.length, // consider using friendCount instead
                        uid
                    }
                })
            })
    )));

    // after downloading any found user data, return array of user data,
    // in the same order as usernameIdPairs.
    return usernameIdPairs
        .map(({uid}) => uidToDataMap[uid] || null);
}

Note: While the above code functions, it's still inefficient. You could improve performance here by using some third-party text search solution and/or hosting this search in a Callable Cloud Function.

Incorrect use of _.debounce

In your code, when you call handleSearch as you type, the instruction setSearchText is called, which triggers a render of your component. This render then removes all of your functions, the debounced function, getUsers and so on and then recreates them. You need to make sure that when you call one of these state-modifying functions that you are prepared for a redraw. Instead of debouncing makeRemoteRequest, it would be better to debounce the handleSearch function.

const handleSearch = _.debounce(async (text) => {
  setSearchText(text);
  // ...
}, 250);

Sub-optimal use of useEffect

In your code, you make use of useEffect to call makeRemoteRequest(), while this works, you could use useEffect to make the call itself. You can then remove all references to makeRemoteRequest() and use the triggered render to make the call.

const SearchScreen = ({ navigation }) => {
  const [searchText, setSearchText] = useState(""); // casing searchText to lowercase is handled by `getUsers` and `searchUsernames`, no need for two state variables for the same data
  const [data, setData] = useState([]);
  const [expanding, setExpanding] = useState(true); // shows/hides footer in FlatList (renamed from "loading")
  const [refreshing, setRefreshing] = useState(false); // shows/hides refresh over FlatList
  const [userNumLoad, setUserNumLoad] = useState(20);
  const [error, setError] = useState(""); // note: error is unused in your code at this point

  // decides whether a database call is needed
  // combined so that individual changes of true to false and vice versa
  // for refreshing and expanding don't trigger unnecessary rerenders
  const needNewData = refreshing || expanding; 

  useEffect(() => {
    // if no data is needed, do nothing
    if (!needNewData) return;

    let disposed = false;

    getUsers(userNumLoad, searchText).then(
      (userData) => {
        if (disposed) return; // do nothing if disposed/response out of date
        // these fire a render
        setData(userData);
        setError("");
        setExpanding(false);
        setRefreshing(false);
      },
      (err) => {
        if (disposed) return; // do nothing if disposed/response out of date
        // these fire a render
        setData([]);
        setError(err);
        setExpanding(false);
        setRefreshing(false);
        //alert("An error has occurred. Please try again later.");
        console.log(err);
      }
    );

    return () => disposed = true;
  }, [userNumLoad, searchText, needNewData]); // only rerun this effect when userNumLoad, searchText and needNewData change

  const handleSearch = _.debounce((text) => {
    setSearchText(text); // update query text
    setData([]); // empty existing data
    setExpanding(true); // make database call on next draw
  }, 250);

  const handleRefresh = async () => {
    setRefreshing(true); // make database call on next draw
  };

  const handleLoadMore = async () => {
    setUserNumLoad(userNumLoad + 20); // update query size
    setExpanding(true); // make database call on next draw
  };

  const renderFooter = () => {
    if (!expanding) return null;

    return (
      <View style={{ paddingVertical: 20 }}>
        <ActivityIndicator animating size="large" />
      </View>
    );
  };

  return ( /** your render code here */ );
}
like image 72
samthecodingman Avatar answered Oct 19 '22 06:10

samthecodingman


Could you log your users variable in getUsers's then callback?

Also, check your FlatList component's style object (styles.list). It is missing in StyleSheet!

like image 38
Taehyun Hwang Avatar answered Oct 19 '22 06:10

Taehyun Hwang