Creating a Fun Facts App Using Typescript, Expo, and Redux

Six is the only number that is both the sum and the product of three consecutive positive numbers.

Mark Pekala
16 min readMay 6, 2021

Goals Of This Project

  • Familiarize yourself with Expo as a tool to build mobile apps
  • Leverage Typescript to write stable, readable code
  • Understand how to use Redux to structure and manage app state
  • Learn some fun facts!

So What Are We Building, Exactly?

The app we are building will have two main pages. The home page will have a search bar where the user can enter a number and receive a fun fact about that number courtesy of the Numbers API. Users can like or dislike certain facts to come back to later. Then, on the history page, the user can see facts that they have liked and disliked.

It’s not much, but it’s enough to give us plenty of space to explore all the tools we’ll be using. (Hopefully it’s at least mildly amusing as well.)

The Code

In case you want to look at the code of the finished project, you can find it at https://github.com/mpekala23/fun-facts.

Installing Tools

The first thing we’ll need to do is make sure that we have NodeJS installed. If you need to install it, you can visit this link to get the latest version. You can check that your install was successful by opening a new terminal and running

npm -v

If something like 7.10.0 prints, you’re all set!

Next, we’ll install yarn to manage project dependencies. Yarn is a package management tool that is very similar to npm. You can install it by running

npm install -g yarn

You can check for successful install by opening a new terminal and running yarn -v. If something like 1.22.10 prints, you’re all set! (If you really want to use npm you can, although some of the commands below may look a bit different. If you’re curious as to what benefits yarn offers over npm, I encourage you to check out this article.)

Next, we’ll need to install Expo. Using yarn, the command to run is

yarn add global expo-cli

Like before, you can check for a successful install by running

expo --version

Initializing The Project

Open a terminal wherever you’d like your project to live. Run

expo init fun-facts --yarn

You should then receive the following prompt:

? Choose a template: › - Use arrow-keys. Return to submit.----- Managed workflow -----blank                 a minimal app as clean as an empty canvasblank (TypeScript)    same as blank but with TypeScript configurationtabs (TypeScript)     several example screens and tabs using react-navigation and TypeScript----- Bare workflow -----minimal               bare and minimal, just the essentials to get you startedminimal (TypeScript)  same as minimal but with TypeScript configuration

Use the arrow keys to select blank (TypeScript) under the Managed workflow section and hit enter. It may take a minute to install dependencies. Once everything is installed, run

cd fun-facts
yarn start

to start developing! This should open a page in your browser that looks like this

In order to run the project on your phone, download the Expo app (iOS, Android) and scan the QR code in your browser. If everything goes well, your phone screen should look like this

Organizing The Project

The template we chose does not come with any built-in project structure besides the assets folder in the root directory. Create folders in your project directory to match the following:

assets/
src/
api/
common/
navigations/
store/
styles/
views/
types.ts
App.tsx
app.json
package.json
...other files

Here’s some quick descriptions for what each of the folders will contain:

  • assets: Images, sounds, etc. that we will use in our app.
  • api: Contains files for interacting with the Numbers API to retrieve fun facts.
  • common: Building blocks that will be used across our app. Things like buttons, cards, etc.
  • navigations: Organizing the different views in our app and how the user will navigate between them.
  • store: Contains all the redux code for storing and managing app state.
  • styles: Contains globally shared code for styling components. Things like colors, spacing, etc.
  • views: Contains react components that represent entire screens of the app that the user can navigate between.
  • types.ts: A Typescript file containing shared type definitions.

Although setting up this thorough of a file structure for a simple app may seem like overkill, it’s good practice for larger projects where even simple refactoring can be overwhelming.

Creating The Home Page

In the src/views directory, create a file named Home.tsx with the following

The first two lines import necessary components provided by React and React Native. Lines 4–9 define the styles that will be used on this screen. Lines 11–17 contain the function definition that will actually render the screen.

Then, to see this component in action, update App.tsx in the root directory to

What this update is doing is replacing the default component with our new HomeScreen. We also render a StatusBar, which takes up the portion of the screen where phones usually display wifi, time, battery, etc. so our content doesn’t get rendered under that. Upon saving the file, your app should automatically update but if it doesn’t you can shake your phone to pull up the developer menu, and then hit reload.

Creating App Styles

The color scheme above isn’t exactly the most appealing. Create a Colors.ts file in the src/styles directory:

While we’re working with styles, let’s also create a file with some handy spacing constants so we don’t have to write them out in every StyleSheet:

When we want to use these values in future files we’ll end up doing something like

import * as Colors from "src/styles/Colors";
import * as Spacing from "src/styles/Spacing";
...Colors.Dark;
Spacing.padding;

Revamping The Home Screen

Now that we have some good shared styles established, it’s time to make the Home screen look less terrible.

Don’t worry too much about understanding all the changes we’ve made. All of them are just cosmetic, using the basic components provided by React Native (TextInput, TouchableOpacity) to accept user input. Styles are something you learn through experience (and most people end up looking up anyway).

Making The Common FunFactCard Component

Throughout our app, there are going to be multiple places where we’ll want to display a fun fact in a pretty way. On the Home screen, we’ll need to display a fun fact to users as they decide whether to “Like” or “Dislike” it. On the History screen, we’ll need to display many fun facts, some that the user liked, and some that the user disliked.

Before we make the component to display a fun fact, let’s define how we are going to represent fun facts. In the src/types.ts file, add the following interface:

export interface FunFact {
fact: string;
rating: 1 | -1 | 0;
}

Now, in the src/common folder, create FunFactCard.tsx

Again, don’t worry too much about the styles. The important part of this file is the usage of Typescript to spec out the components props (that is, what info it expects to receive in order to render a fun fact). We have

interface Props { 
funFact: FunFact;
}
...const FunFactCard: React.FC<Props> = ({ funFact }) => { ... }

If you’re new to Typescript, this may be a bit jarring. What it is doing is establishing that the FunFactCard component expects exactly one prop, called funFact, with the FunFact type that we established in src/types.ts. It makes this object available in the component function in a variable called funFact.

Typing out what props components expect like this may seem like a lot of work, but it is useful because it allows us to ensure that components that want to use this component as a subcomponent pass in the right props (that don’t break stuff). Without Typescript, we would not be able to tell at compile time if someone was passing the string “haha lets break this component” as the rating when it expects a number, which would make our code less stable.

Now that we’ve made this component, go back to Home.tsx and import it at the top of the file.

import * as Colors from "../styles/Colors";
import * as Spacing from "../styles/Spacing";
import FunFactCard from "../common/FunFactCard"; // New

Then, replace <Text>PLACEHOLDER FOR THE ACTUAL FUN FACT</Text> with

<FunFactCard
funFact={{ fact: "Placeholder unrated fact", rating: 0 }}
/>
<FunFactCard
funFact={{ fact: "Placeholder liked fact", rating: 1 }}
/>
<FunFactCard
funFact={{ fact: "Placeholder disliked fact", rating: -1 }}
/>

Setting Up The Redux Store

You may be thinking: “Wow, our app looks almost done!” You’re right! But not really. We haven’t added any functionality yet, which is the trickiest part.

Before continuing, we’ll need to install redux and some associated tools to help us set up the store. Kill your development server with ctrl+c (if it’s running), and in a terminal from the project directory run

yarn add @reduxjs/toolkit
yarn add react-redux

Then run yarn start to get it going again. Create a folder called modules inside src/store. Then, create the file src/store/modules/Facts.ts (don’t worry if this is overwhelming, it’s a lot, and we’ll break down the important parts after):

At a high-level, this file is creating a reducer, which is a function that keeps track of some part of the app state and defines the ways in which the app can interact (read / modify) with that app state. The app state that we want to have access to is a pending fact (which we fetch from the api, that the user needs to rate) as well as a collection of facts which the user has already rated.

The first part of this file (after imports) defines this portion of the app state:

interface FactsState {
status: "loaded" | "loading" | "errored";
error: Error | null;
pendingFact?: FunFact;
ids: EntityId[];
entities: Dictionary<FunFact>;
}

The status variable is for tracking the communication status with the Numbers API. Since the facts will be fetched remotely, we want to know when this fetch is loading loaded or errored (as well as the error, or null if none). The pendingFact will be the fact we display on the Home screen. It will be updated when we fetch data from the Numbers API, and can be undefined if no fact has been fetched yet. The ids and entities are slightly more complex, but they are used by the EnitityAdapter (which is set up later in the file) to store facts the user has already rated.

Next, we create the EntityAdapter named FactsAdapter:

const FactsAdapter = createEntityAdapter<FunFact>({
selectId: (funFact) => funFact.fact,
});

For our purposes, it’s fine to think about the EntityAdapter as a tool that lets us easily manage a collection of objects (such as fun facts we have rated) within the reducer. The selectId function we pass tells the adapter how to differentiate between items in the collection. You can learn more about createEntityAdapter here. (If you’re hesitant about using the fact itself as the id, don’t worry, we’ll address potential problems with this later.)

Next, we create an async thunk. In order to understand what a thunk is, we must first understand what a traditional redux action is. An action is a way of changing the state of the reducer. Traditional actions must be synchronous, and they are the only way of modifying the app state. Redux makes it impossible to edit app state by doing something like appState.pendingFact = undefined and instead forces the app to edit the state by dispatching actions like store.dispatch(updatePendingFact(newFact)). The actions that update the state cannot be asynchronous because redux needs to be guaranteed that they’ll terminate in finite time without error in order to reliably provide state updates. While this may seem like unnecessary complexity, protecting the app state like this makes the app behave very predictably and makes it very difficult to introduce bugs relating to how app state is shared and updated between components.

So what is an async thunk then? Well, often times we want to update the state with an asynchronous function. For example, on app open, we may want to populate the state with information that we fetch asynchronously from a database. In our case, when the user enters a new number, we want to get a new fact from the Numbers API and then update the pendingFact variable in the app state. What an async thunk does is translate this asynchronous action into a series of synchronous actions that can be dispatched to the reducer.

export const getNewFact = createAsyncThunk<FunFact, number>(
"facts/getNew",
async (num) => {
throw Error("Not implemented yet");
}
);

For as complicated as it sounds, it’s not much code. We’ll come back to replacing the throw Error("Not implemented yet"); with actual code to fetch a new fact. The "facts/getNew" is the name of the action, and the function provided is what will be run when this is dispatched to the reducer. The typings of createAsyncThunk<FunFact, number> mean that the action will return a FunFact, and expects a number as input.

When this action is run, it will dispatch a facts/getNew/pending action to the store. When it is fulfilled, it will dispatch a facts/getNew/fulfilled action to the store with the fact that it returned. When it errors (which it always will now), it will dispatch a facts/getNew/rejected action to the store with the error. These are all auto-generated actions, and we will see how we handle them in the reducer later.

Next, we set up the initialState of the reducer, which is pretty straightforward:

const initialState: FactsState = {
status: "loaded",
error: null,
pendingFact: undefined,
...FactsAdapter.getInitialState(),
};

Then, we actually assemble the reducer and export it and its actions using the state, adapter, and thunk we set up previously:

const FactsSlice = createSlice({
name: "facts",
initialState,
reducers: {
addFact: FactsAdapter.addOne,
},
extraReducers: (builder) => {
builder.addCase(getNewFact.pending, (state) => {
state.status = "loading";
state.error = null;
});
builder.addCase(getNewFact.fulfilled, (state, action) => {
state.status = "loaded";
state.pendingFact = action.payload;
});
builder.addCase(getNewFact.rejected, (state, action) => {
state.status = "errored";
state.error = action.error as Error;
});
},
});
export const { addFact } = FactsSlice.actions;
export default FactsSlice.reducer;

You can learn more about the createSlice tool here. (It’s basically a streamlined way of creating a reducer function.) We have to pass it basic info, like name and initialState. The reducers objects accepts plain redux actions (not thunks) which we can use to modify state. Here we pass the FactsAdapter.addOne function as addFact, which we’ll use to add a fact to the collection of rated facts once a user rates it.

The real magic is in the extraReducers function. Don’t worry too much about the syntax here, but what we’re doing is listening to the pending fulfilled and rejected parts of the async thunk we made earlier. When getNewFact is pending, we set the status to "loading" and remove any errors. When it is fulfilled, we set the status to "loaded" and the pendingFact to the result. When it errors, we set the status to "errored" and store the error in the state in case we want to process it later.

And that’s it for the heavy work of the reducer! To actually start using the redux store in our app, we need to create src/store/index.ts:

Don’t worry too much about this file. It just uses the reducer we just defined, along with some functions provided by redux to create the store object and exports it.

Providing The Store To React

Our store has been created! Yay! But now we need to make sure it’s being provided to all of our react components and is accessible using the hooks provided by react-redux.

To do this, we’ll revise App.tsx in the project directory again to include a Provider:

You may need to do a hard refresh of the app (shake screen) in order to get the store changes to take effect.

Using The Store In The Home Screen

In order to incorporate the functionality that we just added with the store, we need to make some changes to the home screen. First, we’re going to keep track of what number the user is typing in a state variable. To simplify the input, we’ll only accept non-negative integers.

// State variables
const [num, setNum] = useState(6);
...<TextInput
value={num >= 0 ? num.toString() : ""}
onChangeText={(val) => {
if (val.length) setNum(parseInt(val));
else setNum(-1);
}}
style={Styles.inputContainer}
keyboardType="number-pad"
/>

Then, we’ll initialize some values from the redux store that we’ll need throughout the component:

const HomeScreen: React.FC = () => {
// Store values
const dispatch = useTypedDispatch();
const pendingFact = useTypedSelector((state) =>
state
.facts.pendingFact
);
const factError = useTypedSelector((state) => state.facts.error);
// State variables
const [num, setNum] = useState(6);
...

These redux hooks may look daunting at first, but with experience they become much more manageable. All they are doing are exposing the values in the app state (like pendingFact) to this component and providing this component a way of dispatching actions to the store (by calling dispatch(...)). Next, we’ll add a “Submit” button under the input that the user can press to get facts on a new number.

<TextInput
...
/>
<Button
title="Submit"
color={Colors.LIGHT}
onPress={() => {
Keyboard.dismiss();
dispatch(getNewFact(num));
}}
/>

Then, we’ll replace the placeholder facts with the actual pending fact (but only if it exists!):

<TextInput
...
/>
<Button
...
/>
{pendingFact && <FunFactCard funFact={pendingFact} />}

Finally, we’ll add an effect to handle errors:

const HomeScreen: React.FC = () => {
// Store values
const dispatch = useTypedDispatch();
const pendingFact = useTypedSelector((state) =>
state
.facts.pendingFact
);
const factError = useTypedSelector((state) => state.facts.error);
// State variables
const [num, setNum] = useState(6);
// Respond to the factError changing
useEffect(() => {
if (factError) {
alert(factError.message);
}
}, [factError]);
return ( ... );
}

The Home.tsx file should now look like this:

When you open the app, type in a number, and hit submit you should see:

Yay!! Well, noooooo!!! But at least it’s something, and it means our store is working as intended.

Integrating The Numbers API

The biggest piece of the puzzle left is the task of actually getting fun facts, a pretty important part of a fun facts app. We’ll start by writing a simple function to hit the Numbers API and return a fact to the getNewFact thunk we made earlier. In the src/api folder, create a file called index.ts:

This function should be straightforward enough, given we accept fetch for the magical function it is.

To actually use this function in our app, we simply need to replace the

throw Error("Not implemented yet");

in getNewFact(src/store/modules/Facts.ts) with

const factString = await fetchFunFact(num);
return {
fact: factString,
rating: 0,
};

Now this is more like it! Now that we have facts to rate, we can add functionality to the LIKE and DISLIKE buttons at the bottom of the Home screen:

import { addFact, getNewFact } from "../store/modules/Facts";...<View style={Styles.feedbackContainer}>
<TouchableOpacity
style={Styles.feedbackButton}
onPress={() => {
if (!pendingFact) return;
const newFact = {
...pendingFact,
rating: 1 as 1,
};
dispatch(addFact(newFact));
dispatch(getNewFact(num));
}}
>
<Text
style={[Styles.feebackButtonText, { color: Colors.PRIMARY }]}
>
LIKE
</Text>
</TouchableOpacity>
<TouchableOpacity
style={Styles.feedbackButton}
onPress={() => {
if (!pendingFact) return;
const newFact = {
...pendingFact,
rating: -1 as -1,
};
dispatch(addFact(newFact));
dispatch(getNewFact(num));
}}
>
<Text
style={[
Styles.feebackButtonText,
{ color: Colors.SECONDARY }
]}
>
DISLIKE
</Text>
</TouchableOpacity>
</View>

Looks great! There’s one small “bug” that you may have noticed. There are only a finite number of facts that exist on the API for any given number, so it’s possible that the user may rate the same fact multiple times. This is fine, as people can change their minds, but the store may run into issues because we are using the fact itself as the unique key in the FactsAdapter, so if we try to add multiple ratings of the same fact things may break. (Two entities with the same id = weird behavior.) Luckily, there’s a very simple fix: upsert.

The upsert operation is a combination of update and insert. It takes a FunFact object as its payload. When there are no entities with the given id in the store, it is inserted. However, when an entity with the given id is already exists, it is updated. It’s a five line change in total:

src/store/modules/Facts.ts:

reducers: {
upsertFact: FactsAdapter.upsertOne,
// Previously addFact and addOne
},
...export const { upsertFact } = FactsSlice.actions;
// Previously addFact

src/views/Home.tsx:

import { upsertFact, getNewFact } from "../store/modules/Facts";...dispatch(upsertFact(newFact));
// Previously dispatch(addFact(newFact));
...dispatch(upsertFact(newFact));
// Previously dispatch(addFact(newFact));

Now the core functionality of fetching and rating facts should be bug free!

Creating a Navigator

Before can add the History screen, we’ll need to add navigation to our app so that it can support multiple screens. Kill the development server, and install some more dependencies:

yarn add @react-navigation/native
expo install react-native-gesture-handler react-native-reanimated react-native-screens react-native-safe-area-context @react-native-community/masked-view
yarn add @react-navigation/bottom-tabs

Boot the development server back up with yarn start and then create a src/navigations/index.tsx file with contents

Then, in App.tsx, instead of rendering the Home screen directly, we’ll render this Navigator instead

...import Navigator from "./src/navigations";...<Provider store={store}>
<View style={Styles.statusBarContainer}>
<StatusBar barStyle="light-content" />
</View>
<Navigator />
</Provider>

The tab bar at the bottom doesn’t do a whole lot now since there’s only one screen, but that’s about to change.

Creating The History Screen

Before we can create the history screen, we’ll need to make one last tweak to our redux store. Right now, our rated facts are being stored as Entities which are not really mean to be accessed directly using the ids and entities variables in the redux store. Instead, we want to use the selectAll selector provided by the EntityAdapter as an optimized tool for accessing all the values in a collection in array form. Towards the bottom of src/store/modules/Facts.ts add

...export const { upsertFact } = FactsSlice.actions;export const { selectAll: selectAllFacts } =
FactsAdapter.getSelectors();
export default FactsSlice.reducer;

With this last bit of support, we can then create the History screen in one fell swoop:

Again, ignore all the style stuff. The most important lines are line 25 (where we user our selector) and lines 28–37, which render the data in list format using FlatList (an optimized component for potentially large scrollable lists).

The only thing left to actually get this screen to show up in our app is to add it to src/navigations/index.tsx

...import HistoryScreen from "../views/History";...<Tab.Navigator
tabBarOptions={{
style: { backgroundColor: "black" },
keyboardHidesTabBar: true,
labelStyle: { fontSize: 24, color: "white" },
}}
>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="History" component={HistoryScreen} />
</Tab.Navigator>

And just like that, we’re done!

Thanks for following along, I hope this was informative and you learned some fun facts along the way.

--

--