Stories are now a trending feature of most social media applications, including WhatsApp, Snapchat, Instagram, and others. This feature gives us another avenue to share media in the form of images, videos, and text to your contacts or friends, and let you know who viewed your story. One of the more appealing aspects of stories is that they are impermanent — they are usually viewable for only 24 hours.

So if you know, why are you here?
Oh! I got it. You need the tutorial on how to develop your own stories feature using React Native and Firestore! Let’s get started.
I’ve configured the basic project setup with React Navigation, Redux and Firebase Authentication, and the Firestore database. Let’s review the database structure before moving forward!
users → <userIds> → <userData>

users → <userId> → stories → <storyId> → <storyData>

The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
Now, we have to achieve three targets:
So let’s start with the first point!
Let’s start by picking some images from Expo’s Image Picker and converting them into a blob in order to upload to Firebase Storage and upload/add records to Firestore collections.
_handleSelectImage = async () => {
let result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: "Images"
});
if (!result.cancelled) {
this.setState({ image: result.uri });
}
};
_handleSubmit = async () => {
const { userId } = this.props;
const { image, title } = this.state;
if (image) {
try {
// Converting image to blob
const image = await blobMaker(image);
// Uploading image in Firebase storage
const tempImage = await firebase
.storage()
.ref()
.child(`images/${new Date().getTime()}.jpg`)
.put(image);
const imageURL = await tempImage.ref.getDownloadURL();
const createdAt = firebase.firestore.Timestamp.now().toMillis();
// Preparing object to be pushed in Firestore
const payload = {
image: imageURL,
viewedBy: [],
createdAt
};
if (title) {
payload.title = title;
}
// Pushing story data into `stories` subcollection of collection `users`
await firebase
.firestore()
.collection("users")
.doc(userId)
.collection("stories")
.add(payload);
// And updating the last story time in user's document, this will help us to sort by latest story in the list screen
await firebase
.firestore()
.collection("users")
.doc(userId)
.set(
{
updatedAt: createdAt
},
{ merge: true }
);
this.props.navigation.navigate("Stories")
} catch (error) {
this.setState({ loading: false });
}
}
}
};
render() {
<ScrollView contentContainerStyle={styles.container}>
{/* Title View */}
<View style={styles.inputContainer}>
<Text>Title (Optional)</Text>
<TextInput
style={styles.input}
value={title}
onChangeText={title => this.setState({ title })}
/>
</View>
{/* Image View */}
<View style={styles.buttonContainer}>
<Button
title={image ? "Change Image" : "Select Image"}
style={styles.button}
onPress={this._handleSelectImage}
/>
{image && <Image source={{uri: image}} style={styles.image}/>}
</View>
{/* Submit View */}
<View style={styles.buttonContainer}>
<Button
title="Submit"
style={styles.button}
onPress={this._handleSubmit}
/>
</View>
</ScrollView>
}

Congratulations! We are done with uploading our very first image/story to Firebase storage and updating the record in Firestore. Now let’s move to the second target.
So, we have added records to the Firestore user collections. Now let’s get those records. First, we need to make a Firebase query for all user collections with Snapshot. Why Snapshot, you ask? Because we need real-time data for all users.
listenAllUsers = async () => {
const { userId } = this.props;
try {
// Listening to users collections
await firebase
.firestore()
.collection("users")
.onSnapshot(snapshot => {
if (!snapshot.empty) {
let user;
let allUsers = [];
snapshot.forEach(snap => {
const data = { ...snap.data(), _id: snap.id };
if(data._id === userId) {
user = data;
}
else {
allUsers.push(data);
}
});
this.setState({ allUsers, user });
}
});
} catch (error) {
console.log("listenAllUsers-> error", error);
}
};
Now that we have all the users, let’s save them for later by updating state. Our goal is to get all users who have stories within the last 24 hours — so what should we do?
We have to filter those from all users with an interval loop that will rerun the function so that we get the story statuses up to date.
componentDidMount() {
// Listening for all users
this.listenAllUsers();
// Interval
this.timeIntervalSubscription = setInterval(() => {
if (this.state.allUsers.length) {
// Filtering all users
this.filterUsers();
}
}, 500);
}
filterUsers = () => {
const { allUsers } = this.state;
const filterUsers = allUsers.filter(user => dateIsWithin24Hours(user.updatedAt));
this.setState({ filterUsers });
};
Now we just need to render the things. I’ve created my own styling component (AvatarWithStory) to render them — you can try your own!
render() {
const { user, filterUsers, allUsers } = this.state;
return (
<ScrollView contentContainerStyle={styles.container}>
{/* My story */}
<View style={styles.containerWithPadding}>
<AvatarWithStory
hasStories={dateIsWithin24Hours(user.updatedAt)}
user={{ ...user, time: dateFormatter(user.updatedAt) }}
/>
)}
</View>
<HrWithText text={`Other Users (${filterUsers.length})`} />
{/* All users */}
<View style={styles.containerWithPadding}>
{filterUsers &&
filterUsers.map(user => (
<AvatarWithStory
user={{ ...user, time: dateFormatter(user.updatedAt) }}
/>
))}
</View>
</ScrollView>
);
}
}

Congrats! We have just hit our second target. Now let’s move on to the last target.
Now we are in the very last phase of our app: we need to render selected user stories/statuses. Considering that we are getting the user ID from props or the selected user’s navigation params, all we need to do is query that and get data from its sub-collection.
For swiping images, I’m using react-native-banner-carousel.
componentDidMount() {
// Listening for the selected user story
this.fetchSelectUserStory();
}
fetchSelectUserStory = async () => {
// Updating currentIndex from -1 to 0 in order to start stories
this.setState(pre => ({ ...pre, currentIndex: pre.currentIndex + 1 }));
// Previous 24 hours server time
const currentTimeStamp =
firebase.firestore.Timestamp.now().toMillis() - 24 * 60 * 60 * 1000;
try {
// Listening for selected users sub-collections of stories where createdAt is greater than currentTimeStamp
const tempStories = await firebase
.firestore()
.collection("users")
.doc(this.props.navigation.state.params.id) // Here considering userId is from navigation props
.collection("stories")
.orderBy("createdAt", "asc")
.where("createdAt", ">", currentTimeStamp)
.get();
if (!tempStories.empty) {
const stories = [];
tempStories.forEach(story => {
stories.push({
...story.data(),
id: story.id
});
});
// Updating state according to fetched stories
this.setState({ stories });
// Changing slide
this.interval();
}
} catch (error) {
console.log("fetchSelectUserStory -> error", error);
}
};
Like WhatsApp, we can check who has seen my story, an awesome feature! So let’s add that, too, in our application. When users view my story, all we need to do is update the Firestore sub-collection with those users’ IDs.
// Will run on page change
onPageChanged = async index => {
const { stories } = this.state;
const { userId } = this.props;
// Getting active story from state
const activeStory = stories[index];
// Updating currentIndex
this.setState({ currentIndex: index });
// Changing slide
this.interval();
// Checking whether user already viewed the story
const alreadyViewed = activeStory.viewedBy.filter(
user => user === userId
);
// If already viewed, return from function
if (alreadyViewed.length) {
return;
}
// If not, then update record in Firestore
try {
await firebase
.firestore()
.collection("users")
.doc(this.props.id)
.collection("stories")
.doc(activeStory.id)
.set(
{
viewedBy: [...activeStory.viewedBy, this.props.userId]
},
{ merge: true }
);
} catch (error) {
console.log("TCL: Story -> error", error);
}
};
Let’s also add auto-swipe to the story for a more natural feel. What about 10s? I think that’s too much — let’s just stick to 6s.
interval = () => {
// Clearing timeout if previous is in subscription
if (this.clearTimeOut) clearTimeout(this.clearTimeOut);
// New subscription for current slide
this.clearTimeOut = setTimeout(() => {
const { currentIndex, stories} = this.state;
// If current slide is the last slide, then remove subscription
if (Number(currentIndex) === Number(stories.length) - 1) {
clearTimeout(this.clearTimeOut);
} else {
// Updating current slide by 1
this.setState({ currentIndex: currentIndex + 1 });
// Checking if carousel exists (ref: check <Carousel /> in render())
if (this._carousel) {
const { currentIndex} = this.state;
// If yes, then move to next slide
this._carousel.gotoPage(currentIndex);
}
}
}, 6000);
};
Have a look at our render functions:
// Render single slide
renderPage = (story, index) => {
// Changing slide on press
const onPress = () =>
{
this.setState(pre => ({
...pre,
currentIndex:
pre.currentIndex === pre.stories.length ? 0 : pre.currentIndex + 1
}));
this._carousel.gotoPage(this.state.currentIndex);
this.interval();
}
return (
<TouchableOpacity
onPress={onPress}
>
<View key={index}>
<Image source={{ uri: story.image }} />
{story.title && (
<View>
<Text style={styles.overlayText} numberOfLines={3}>
{story.title}
</Text>
</View>
)}
</View>
</TouchableOpacity>
);
};
// Pause slider function
pauseSlider = () => clearTimeout(this.clearTimeOut);
// Go back to screen
goBack = () => this.props.navigation.navigate("StoriesScreen");
// Close modal
closeModal =() =>
{
this.setState({ modalVisible: false });
this.interval();
}
render() {
const { currentIndex, stories, isLoading, stories } = this.state;
return (
<View style={styles.container}>
{/* Header View */}
<View style={styles.topContainer}>
{/* Progress Bars on the top of story. See the component below */}
<TopBar
index={currentIndex}
totalStories={stories.length}
isLast={currentIndex === stories.length- 1}
/>
<Header
goBack={this.goBack}
user={this.props.user}
views={
stories[currentIndex] && stories[currentIndex].viewedBy.length
}
viewsOnPress={this.setModalVisible}
/>
</View>
{/* Carousel Images View */}
<View style={styles.bottomContainer}>
<Carousel
ref={ref => (this._carousel = ref)}
autoplay={false}
loop={false}
pageSize={BannerWidth}
onPageChanged={this.onPageChanged}
index={currentIndex === -1 ? 0 : currentIndex}
showsPageIndicator={false}
>
{stories.map((story, index) => this.renderPage(story, index))}
</Carousel>
</View>
</View>
{/* Viewed By View */}
<Modal
animationType="slide"
transparent={false}
visible={this.state.modalVisible}
onRequestClose={() => {
this.setState({ modalVisible: false });
this.interval();
}}
>
<ScrollView>
<View style={styles.viewedBy}>
<Text>Viewed By</Text>
<TouchableOpacity
onPress={this.closeModal}
>
<Text>Close</Text>
</TouchableOpacity>
</View>
{this.state.storiesViewedBy.map(user => (
<AvatarWithStory user={{ ...user }} />
))}
</ScrollView>
</Modal>
);
}


And here’s the component for the progress bar at the top of a story:
// Setting current index of stories & number of stories to state
static getDerivedStateFromProps(nextProps, prevState) {
return {
currentIndex: nextProps.index,
noOfStories: nextProps.totalStories
};
}
componentDidMount() {
this.updateNoOfProgress();
}
componentDidUpdate(prevProps, prevState) {
// Checking if slide changed
if (prevProps.index !== this.props.index) {
// If yes, then clear interval
if (this.interVal) clearInterval(this.interVal);
// Reset and update progress bar
this.updateNoOfProgress();
}
}
// Resetting progress bar
updateNoOfProgress = () => {
const duration = 60;
this.setState({ noOfProgress: 0 });
this.interval = setInterval(() => {
const { noOfProgress } = this.state;
// If progress bar is complete, then clear interval
if (noOfProgress === 100) {
clearInterval(this.interval);
} else {
// Otherwise, keep updating progress bar by 1
this.setState(pre => ({ ...pre, noOfProgress: pre.noOfProgress + 1 }));
}
}, duration);
};
render() {
const { currentIndex, noOfStories, noOfProgress } = this.state;
return (
<View style={styles.container}>
{[...Array(noOfStories)].map((story, index) => (
<View
style={[
styles.single,
{ width: Math.floor(width / noOfStories) - noOfStories }
]}
key={index}
>
<ProgressBarAndroid
styleAttr="Horizontal"
indeterminate={false}
progress={
!(index >= currentIndex)
? 1
: index === currentIndex
? noOfProgress / 100
: 0
}
style={styles.bar}
color="#fff"
/>
</View>
))}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginTop: StatusBar.currentHeight,
width,
height: height * 0.03,
paddingTop: height * 0.01,
flexDirection: "row",
justifyContent: "space-evenly"
},
bar: { transform: [{ scaleX: 1.0 }, { scaleY: 1 }], height: height * 0.01 },
single: { marginLeft: 1 }
});
Finally! We have achieved our third and last goal. Check out the demo below, and also check the GitHub repo for more details and working code. You can also directly run it via Expo.

Thank you for reading the post! Hopefully it helped meet your needs!

LogRocket's Galileo AI watches sessions for you and and surfaces the technical and usability issues holding back 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.

:has(), with examplesThe CSS :has() pseudo-class is a powerful new feature that lets you style parents, siblings, and more – writing cleaner, more dynamic CSS with less JavaScript.

Kombai AI converts Figma designs into clean, responsive frontend code. It helps developers build production-ready UIs faster while keeping design accuracy and code quality intact.

Discover what’s new in The Replay, LogRocket’s newsletter for dev and engineering leaders, in the October 22nd issue.

John Reilly discusses how software development has been changed by the innovations of AI: both the positives and the negatives.
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 now
One Reply to "Mimic WhatsApp stories using React Native and Firestore"
Awesome! One question – from this example how would you go about showing stories from only your friends?