How I Built a Language Learning App With React Native
As an Italian living in Berlin, I’m attending German classes, where I was introduced to the concept of flashcards for memorizing words that we learned during lesson. The idea is incredibly simple: you write down the newly learned German word on a piece of paper, and then on the back-side of it you write the translation in your native language. Then, when you have a collection of cards, you train your memory: just put them on the table with the English (or, in my case, Italian) side up, pick one and try to remember the German translation for it. Then you flip it and check if you were right. If that’s the case, you continue with the other cards. Otherwise you flip it back again and you proceed with the other cards. Additionally, you can also divide them in different boxes, based on how well you know these words. Once you mastered a word you can move it to the next group. I then discovered that this method is called Leitner system, named after Sebastian Leitner, a German science journalist who created the system based on the principle of Spaced Repetition.
After doing this for a while with real paper cards, I thought:
“there must be an app for this!”
And yes, I was right: there are plenty of apps based on this method. However in all the ones that I’ve tried I’ve felt that something was missing. Most of them were generic flashcards apps: that means that you could create your set of cards for learning literally anything: city capitals, historical dates etc. It doesn’t really matter what is the topic because you can type whatever you want on the card. So of course, this can be used for languages as well, but the downside is that creating a card takes some time and you can potentially introduce errors like typos, wrong articles and so on. Some others were specifically made for languages, but they were relying on external APIs therefore they didn’t provide any offline capabilities and in most cases they didn’t implement autocomplete to limit the number of requests to the API. Last but not least, in many cases the UI was really poorly designed and over-complex for no reason.
This led me to understand the requirements of my App:
- The creation of cards needs to be fast and error-proof. Ideally it should have autocomplete capabilities
- I don’t want to rely on external APIs and I want it to work even offline
- The UI needs to be modern and simple
Step 1: tech stack
At the very beginning I considered the idea of learning SwiftUI and build the app for iOS only. But then I thought about my initial target audience (and potential Beta testers) and after a quick survey in my class and within my friends I realized that actually a good 80% of them was on Android. Therefore I started thinking about a framework that allowed me to code the App once and then build it for both platforms. This basically left me with:
Considering that I’m already using React for basically all of my personal and professional projects I guess the choice was kind of obvious 🙂
Step 2: design
I started sketching on paper how the app should look like. I came out with these main views:
- Wallet: this is where you store your words. These words can then be used for creating card decks
- Add a word: this is the interface for adding a word to the wallet, with autocomplete functionality
- Training mode: this is where you can generate your card decks and then train your memory by swiping and flipping the cards
- Challenge mode: this is where you can test your knowledge of a word and record your progress
Once the idea was quite clear on paper, I decided to design the actual UI on Sketch
The original color scheme was completely different than the one that I eventually adopted but the main structure is almost unchanged.
Step 3: technical challenges
The learning curve for React Native was rather smooth. Once you get used to have the
View component instead of
Text instead of
p the rest is basically like good old regular React. If you are familiar with CSS Flex, then the styling and layouting system are quite straightforward as well.
I structured my project in order to use Typescript and ESlint for code linting. All my components were created by using Functional Components and I entirely relied on React Hooks for state management.
The most complex part was about data storing. I had 2 main needs:
- Wallet: the wallet data needs to be persisted on the device. It needs to be safely stored when then App is force-quitted and even when the App receives an update. Therefore I obviously could not rely on the React State for this, otherwise a user would lose all the data just by simply quitting the app.
- Dictionary: as I mentioned, the App has offline capabilities which means that the entire German dictionary is available on the device. I need to be able to query it with a good performance, which is quite challenging considering that the dictionary is ~200.000 words. The good part is that the user will not have write access in this DB.
I decided therefore to go with 2 different solutions. For the wallet I used React Native Async Storage which is a key-value storage system. This allowed me to easily setup the CRUD operations for the wallet. However this solution was not viable for the dictionary: there are no indexing capabilities, and this made the autocomplete basically impossible to implement. Also, all data needs to be deserialized after being retrieved and doing so with hundreds of thousands of lines would be simply crazy.
Therefore for the dictionary I decided to rely on an SQLite database and use an excellent library called react-native-sqlite-storage which allowed me to import a pre-populated database that I would ship together with the app. This is compatible with both iOS and Android. However, some extra magic was needed in order to have the full functionality on Android as well. In fact, the queries that I execute for the auto-complete functionality rely on the use of Full Text Search (FTS5) which allows me to do some things like:
- Have an accent insensitive collation (for example I can type “traumen” and get back the word “träumen”).
- Execute the search not only on the German word, but on the English translation as well (for example I can search for “Guitar” and get back “die Gitarre”).
- I can rank these results and then sort the results based on the ranking.
However I’ve find out that, while on iOS you can use the device’s SQLite and rely on the built-in FTS5 support, this is not the case for Android. Therefore for Android I had to use the SQLite bundled with the library. Luckily this is well documented on the library, so after some configuration adjustments I was able to run this smoothly on Android as well.
Some extra challenges came as well from the fact that I wanted to take full advantage of the device display cutout (which is called Notch in iOS devices) but at the same time avoid breaking the layout on devices that don’t have this hardware element. I managed to solve this problem by using a library called react-native-safe-area.
Step 4: branding
I didn’t really have to think too much about the name. The codename for the app was FlipCards and this would have probably been the final name if it wasn’t for an already existing App with that name. Therefore I just changed it to Flipping Cards.
Regarding the logo, I had a few ideas that I quickly sketched and then put on the SpringBoard of my iPhone, in order to see how they looked on the real device.
The idea was basically to show a stylized version of the cards view.
After a few brainstorming sessions with my friends, a friend of mine came up with the idea of making it look more 3D, similar to a revolving door.
The day after I opened Sketch and started designing it with this new idea in mind. This is the final result:
Step 5: testing
I will spare you all the details of the technical implementation (you can still check the source code if you’re really curious), but after a couple of months of evening coding-sessions I had a decent and almost shippable version of the App. So it was finally time to let some people test it.
I started from iOS where the procedure was very straight-forward: I just submitted the app to Apple, who took a couple of hours to review it and eventually approve it. After that I was able to setup TestFlight and create the list with the email addresses of my beta testers for iOS. They automatically received an email with the instruction on how to install the App. Moreover TestFlight provides a very simple way to submit feedback directly to the developer.
The other cool thing is that every time that I was pushing a build with an update, my testers were receiving a push notification with the changelog so they knew immediately what to test.
On Android I’ve found the procedure a little bit more chaotic. First of all you have to take care of signing the build file (while with Apple this is automatically done when submitting the App via Xcode). Once the build is successfully submitted to Google there are 3 different release tracks:
- Internal testing
- Closed Testing
- Open testing
When I was ready to send out invites for the closed testing I thought that the download link was broken. It took me some days (and an exchange with Google Developer Support) to realize that it was not working only for me because if you’re an internal tester you can’t at the same time also be in the closed testing list. While this makes total sense, I couldn’t find this information anywhere. In general the UX of the Google Play Console doesn’t seem well thought through. But it’s a product for developers, so maybe they didn’t care so much 🙂
What I find interesting is that on Android, being invited to a test track basically only means that you will have access to the App Play Store link even before the App is published. When the App is released officially, nothing changes for a Beta Tester. On the other hand, iOS has a different approach: the App that you install via TestFlight has an expiration date after 90 days. The App Store link exists only after your App is officially published and so, if testers want to continue using the App, they have to download the official version from the App Store.
Anyway, after approx. one month of bug fixing (with precious feedback from testers) and 10 build versions later, I finally sent the App for final review on both platforms, where it was approved in just a few hours.
The feedback that I’m getting so far is very positive and this is obviously an encouragement to continue building the features that didn’t make it in the first release.