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;
Here are some observations about your current code.
/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:
"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.
_.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);
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 */ );
}
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!
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With