Creating a Material Dev.to Client on Six Platforms with 100% Code Sharing

December 09, 2019

react nativereact

Dev.To.Material is a simple Material Dev.to client built with React Native that runs on the Web, Android, iOS, and Electron (Windows, Linux, and MacOS). All code is shared between each platform using React Router, React Native Web, Material Bread, and more.

Unfortunately, much of the Dev.to API is undocumented and authentication with oauth2 is in a private alpha. Therefore, although much of the UI for the Home page and Article page has been created, much of the functionality has not been implemented yet. Currently, however, you can click on articles, sort articles by topic button (feed, week, etc), search articles by tags, and click on tags to sort.

Github

Demo WebsiteDevToMaterial

This article provides a small guide on how to build a React Native app across six platforms while sharing 100% of the code.

  • Setting up a Mono Repo
  • Cross-Platform Router
  • Cross-Platform Utilities and Fonts
  • Cross-Platform UI Components and Layout
  • Cross-Platform Styles and Responsiveness
  • Dev.to API
  • Rendering Post HTML Cross-Platform
  • Conclusion

Setting up a Cross-Platform MonoRepo

Sharing code within a monorepo is significantly easier than sharing code across multiple repos. Additionally, sharing code within a React Native mono repo is surprisingly simple to setup. Essentially, each platform has its own folder that contains the configuration necessary to render the app on that platform. You can learn more about this in my previous article, Creating a Dynamic Starter Kit for React Native.

We're going to use react-native-infinity to generate the minimum configuration required to share code across all platforms. Simply initialize a new project with the name, platforms, and UI library you want to use.

shell
1.npx react-native-infinity init
Initialize New Project

Follow the instructions printed in the terminal to complete the setup.

We now have a cross-platform monorepo that renders the src folder on all platforms. While developing, it's important to constantly test changes on multiple platforms and screen sizes. Often a seemingly insignificant change on one platform can completely break on a different platform.

Cross-Platform Router

Both react-router and react-navigation support web and native routers. However, I kept running into problems with React Navigation, and overall found it much more difficult to use and customize. React Router, on the other hand, was extremely easy to setup and I never ran into any problems. To set up React Router across platforms, we need to install three packages, react-router, react-router-dom, react-router-native.

shell
1.npm install react-router react-router-dom react-router-native

react-router-dom and react-router-native provide the same components (Router, Route, Link, etc) for the web and native (iOS and Android) respectively. All we need to do is import the correct components for each platform. This is easily done using Native-specific extensions, which selects files for particular platforms based off the file extension.

Create a new file src/Router/index.js that exports the react-router-native components.

jsx
1.export {
2. NativeRouter as Router,
3. Route,
4. Switch,
5. Link,
6. } from "react-router-native";

In the same folder, create the file src/Router/index.web.js that exports the react-router-dom components.

jsx
1.export { BrowserRouter as Router, Route, Switch, Link } from "react-router-dom";

Whenever we need to use the router we can import the components from our local folder Router, and the bundler should pick up the correct file.

Next, create the src/Routes.js file to house all the pages in the app. As mentioned above, import the router components from our local folder, Router, rather than the react-router-* packages.

jsx
1.// src/Routes.js
2.
3.import React from "react";
4.import { View } from "react-native";
5.
6.import Home from "./Screens/Home";
7.import Post from "./Screens/Post";
8.
9.import { Route, Router, Switch } from "./Router";
10.
11.function Routes() {
12. return (
13. <Router>
14. <View style={{ backgroundColor: "#f2f6ff", minHeight: "100%" }}>
15. <Switch>
16. <Route exact path="/" component={Home} />
17. <Route exact path="/post/:id" component={Post} />
18. </Switch>
19. </View>
20. </Router>
21. );
22.}
23.
24.export default Routes;

Now, create two very simple screens with Link components to navigate back and forth.

jsx
1.// src/Screens/Home.js
2.
3.import React from "react";
4.import { View, Text } from "react-native";
5.import { Link } from "../Router";
6.
7.export default function Home() {
8. return (
9. <View>
10. <Link to="/post/1">
11. <Text>To Post</Text>
12. </Link>
13. </View>
14. );
15.}
jsx
1.// src/Screens/Post.js
2.
3.import React from "react";
4.import { View, Text } from "react-native";
5.import { Link } from "../Router";
6.
7.export default function Home() {
8. return (
9. <View>
10. <Link to="/post/1">
11. <Text>To Post</Text>
12. </Link>
13. </View>
14. );
15.}

Finally, update src/App.js to use the new Routes we setup.

jsx
1.import React from "react";
2.import { View } from "react-native";
3.import Routes from "./Routes";
4.
5.export default class App extends React.Component {
6. render() {
7. return (
8. <View>
9. <Routes />
10. </View>
11. );
12. }
13.}

You should now be able to navigate between the Home screen and Post screen on each platform.

Cross-Platform Utilities and Fonts

Devices and platforms often have subtle differences that require special rules, for example, the Iphone X's notch. Therefore, we will need to apply styles and other logic per platform. Create src/Styles/device.js, as follows:

jsx
1.import { Platform } from "react-native";
2.
3.const isWeb = Platform.OS == "web";
4.const isAndroid = Platform.OS == "android";
5.const isIos = Platform.OS == "ios";
6.
7.export { isWeb, isAndroid, isIos };

We will often need to reference a device's screen width and height when building the layout. Create src/Styles/dimensions.js to export the dimensions

jsx
1.import { Dimensions, Platform } from "react-native";
2.
3.const screenHeight = Dimensions.get("window").height;
4.const screenWidth = Dimensions.get("window").width;
5.const trueHundredHeight = Platform.OS == "web" ? "100vh" : "100%";
6.
7.export { screenHeight, screenWidth, trueHundredHeight };

Next, create src/Styles/theme.js to hold the apps theme.

jsx
1.import { isWeb } from "./device";
2.
3.const theme = {
4. background: "#f7f9fc",
5. bodyBackground: "#f2f6ff",
6. fontFamily: isWeb ? "Roboto, sans-serif" : "Roboto",
7.};
8.
9.export default theme;

Finally, we need to provide the Roboto font for all platforms. Adding Roboto to the Web and Electron is quite simple, just add an import from Google Fonts in both web/index.html and electron/index.html, between two <style> tags.

jsx
1.@import url("https://fonts.googleapis.com/css?family=Roboto&display=swap");

Adding fonts to iOS and Android is a little more complicated, follow this excellent article to learn how.

Cross-Platform UI Components and Layout

Creating a user interface across screens sizes, platforms, and devices is extremely time consuming. The more components we can share across platforms, the better. With that in mind, we're going to Material Bread which provides Material Design components that work across all platforms. If you added Material Bread with React Native Infinity, then everything is setup already, if not then please visit the docs to get started.

The essential layout is composed of an Appbar, Drawer, and the actual page Content. These can be shared across platforms, but they need to act differently depending on the screen width and screen size.

DevToMaterial

We can create this structure easily with the Drawer component. Page content is rendered as a child of the Drawer component and the Appbar is rendered by the appbar prop.

jsx
1.// src/Screens/Home.js
2.
3.import React, { useState } from "react";
4.import { View, Text, Platform, StyleSheet } from "react-native";
5.import { Drawer } from "material-bread";
6.import { trueHundredHeight } from "../Styles/dimensions";
7.import theme from "../Styles/theme";
8.
9.export default function Home() {
10. const [isOpen, setisOpen] = useState(true);
11.
12. return (
13. <Drawer
14. open={isWeb ? true : isOpen}
15. type={"permanent"}
16. onClose={() => setisOpen(false)}
17. drawerContent={
18. <View>
19. <Text>Drawer Content</Text>
20. </View>
21. }
22. style={styles.pageContainer}
23. drawerStyle={styles.drawer}
24. appbar={<View style={styles.appbar} />}
25. >
26. <View style={styles.body}>
27. <View style={{ flexDirection: "row" }}></View>
28. </View>
29. </Drawer>
30. );
31.}
32.
33.const styles = StyleSheet.create({
34. pageContainer: {
35. height: "auto",
36. minHeight: trueHundredHeight,
37. backgroundColor: theme.background,
38. },
39. drawer: {
40. borderRightWidth: 0,
41. height: "100%",
42. },
43. body: {
44. width: "100%",
45. paddingTop: 34,
46. backgroundColor: theme.bodyBackground,
47. padding: 34,
48. minHeight: trueHundredHeight,
49. },
50. appbar: {
51. height: 56,
52. width: "100%",
53. },
54.});

Although this layout will work across platforms, it won't look good across screen sizes. For example, the drawer will stay open on very small screen sizes and hide all of the content. Therefore, the next problem we need to tackle is responsive styles.

Cross-Platform Styles and Responsiveness

An initial approach at cross-platform responsiveness is use the Dimensions property to create breakpoints.

jsx
1.const isMobile = Dimensions.get("window").width < 767;

The obvious problem is that the values won't update when the width of the window changes. Another approach, is to use React Native's onLayout prop to listen for layout changes on a particular component. A library like react-native-on-layout can make this easier, but it's not ideal in my opinion. Other packages for adding responsivess to React Native are not well supported on the web.

Instead, we can create a hybrid approach by using react-responsive to provide media queries for browsers and use dimensions for native.

jsx
1.const isMobile =
2.Platform.OS == "web" ? useMediaQuery({ maxWidth: 767 }) : screenWidth < 767;

This will update when the browser width is resized and respond to the breakpoint for mobile devices. We can expand this and create some useful responsive components to use across the app.

jsx
1.import { useMediaQuery } from "react-responsive";
2.import { isWeb } from "./device";
3.import { screenWidth } from "./dimensions";
4.
5.// Breakpoints
6.const desktopBreakpoint = 1223;
7.const tabletBreakpoint = 1023;
8.const mobileBreakpoint = 767;
9.
10.// Native Resposive
11.const isDesktopNative = screenWidth > desktopBreakpoint;
12.const isLaptopOrDesktopNative = screenWidth > tabletBreakpoint + 1;
13.const isLaptopNative =
14. screenWidth > tabletBreakpoint + 1 && screenWidth < desktopBreakpoint;
15.const isTabletNative =
16. screenWidth < tabletBreakpoint && screenWidth > mobileBreakpoint + 1;
17.const isTabletOrMobileNative = screenWidth < tabletBreakpoint;
18.const isMobileNative = screenWidth < mobileBreakpoint;
19.
20.// Cross-Platform Responsive Components
21.const Desktop = ({ children }) => {
22. const isDesktop = isWeb
23. ? useMediaQuery({ minWidth: desktopBreakpoint })
24. : isDesktopNative;
25. return isDesktop ? children : null;
26.};
27.
28.const LaptopOrDesktop = ({ children }) => {
29. const isDesktop = isWeb
30. ? useMediaQuery({ minWidth: tabletBreakpoint + 1 })
31. : isLaptopOrDesktopNative;
32. return isDesktop ? children : null;
33.};
34.
35.const Laptop = ({ children }) => {
36. const isDesktop = isWeb
37. ? useMediaQuery({
38. minWidth: tabletBreakpoint + 1,
39. maxWidth: desktopBreakpoint,
40. })
41. : isLaptopNative;
42. return isDesktop ? children : null;
43.};
44.
45.
46.const Tablet = ({ children }) => {
47. const isTablet = isWeb
48. ? useMediaQuery({
49. minWidth: mobileBreakpoint + 1,
50. maxWidth: tabletBreakpoint,
51. })
52. : isTabletNative;
53. return isTablet ? children : null;
54. };
55. const TabletOrMobile = ({ children }) => {
56. const isTablet = isWeb
57. ? useMediaQuery({
58. maxWidth: tabletBreakpoint,
59. })
60. : isTabletOrMobileNative;
61. return isTablet ? children : null;
62. };
63. const Mobile = ({ children }) => {
64. const isMobile = isWeb
65. ? useMediaQuery({ maxWidth: mobileBreakpoint })
66. : isMobileNative;
67. return isMobile ? children : null;
68. };
69.
70. export {
71. mobileBreakpoint,
72. tabletBreakpoint,
73. desktopBreakpoint,
74. isDesktopNative,
75. isLaptopOrDesktopNative,
76. isLaptopNative,
77. isTabletNative,
78. isTabletOrMobileNative,
79. isMobileNative,
80. Desktop,
81. LaptopOrDesktop,
82. Laptop,
83. Tablet,
84. TabletOrMobile,
85. Mobile,
86. };

For example, we can use this to only show the Appbar button "Write a post" on laptop screen sizes and above:

jsx
1.// src/Components/Appbar/Appbar.js
2....
3.actionItems={[
4. <LaptopOrDesktop key={1}>
5. <Button
6. text={"Write a post"}
7. onPress={this.createPost}
8. type="outlined"
9. icon={<Icon name={"send"} />}
10. radius={20}
11. borderSize={2}
12. style={{ marginRight: 8 }}
13. />
14.</LaptopOrDesktop>,
15....

And then show the Fab button on tablet and mobile screen sizes.

jsx
1.// src/Components/Layout.js
2....
3.<TabletOrMobile>
4. <Fab containerStyle={styles.fab} />
5.</TabletOrMobile>
6....
DevToMaterial

Applying the same logic to the Drawer, we can hide the Drawer on mobile. useMediaQuery's third argument takes a callback function and sends along whether the media query matches. We can use this to call setIsOpen to false when the window width is below the mobileBreakpoint.

jsx
1.const handleIsMobile = matches => setisOpen(!matches);
2.
3.const isMobile = useMediaQuery(
4. { maxWidth: mobileBreakpoint },
5. undefined,
6. handleIsMobile
7.);
8.
9.const [isOpen, setisOpen] = useState(isMobile ? false : true);

Lastly, we can set the Drawer type to modal, to match what we would expect on mobile.

jsx
1.<Drawer
2.open={isOpen}
3.type={isMobile ? "modal" : "permanent"}
4....
5./>
6....
DevToMaterial

The rest of the UI was built using similar patterns. If you're interested, check out the github repo to see the rest of the components.

Dev.to API

The Dev.to API is still in beta and much of the functionality has not been documented yet. Therefore, for this app we will only be concerned with fetching posts. If more of the API were open, I might use a more robust state management system, but for now I'll simply create some hooks.

Let's write a simple async function to fetch posts with error handling.

jsx
1.// src/Screens/Home.js
2....
3.const [posts, setPosts] = useState(initialState.posts);
4.const [isLoading, setIsLoading] = useState(initialState.isLoading);
5.const [hasError, setHasError] = useState(initialState.hasError);
6.
7.const fetchPosts = async () => {
8. setIsLoading(true);
9.
10. try {
11.
12. const result = await fetch(`https://dev.to/api/articles`);
13. const data = await result.json();
14. setPosts(data);
15. setHasError(false);
16. } catch (e) {
17. setIsLoading(false);
18. setHasError(true);
19. }
20.};
21.useEffect(() => {
22. fetchPosts();
23.}, []);
24.
25. return (
26. <Layout>
27. <PostList posts={posts} hasError={hasError} isLoading={isLoading} />
28. </Layout>
29. );
30....

Check out the Github Repo to see the PostList component.

The buttons on top of the main card list ("Feed", "Week", etc) are simple filters on the request above. Week, for example, can be fetched by appending top=7 to the original request.

jsx
1.https://dev.to/api/articles/?top=7

We can create a simple function to append these queries onto the root url using the history object from React Router.

jsx
1.function HandleNavigate({ filter, type, history }) {
2. const link = type ? `?${type}=${filter}` : "/";
3.
4. history.push(link);
5. }

Then, back on the Home screen, we can use React Router's location object to append those queries to the fetch.

jsx
1.const fetchPosts = async () => {
2. setIsLoading(true);
3.
4. try {
5. const queries = location.search ? location.search : "/";
6.
7. const result = await fetch(`https://dev.to/api/articles${queries}`);
8. const data = await result.json();
9. setPosts(data);
10. setHasError(false);
11. setTimeout(() => {
12. setIsLoading(false);
13. }, 600);
14. } catch (e) {
15. setIsLoading(false);
16. setHasError(true);
17. }
18.};

Lastly, we need to add the location object to second argument of useEffect so that it will fire fetchPosts when the location object has updated.

jsx
1.useEffect(() => {
2. fetchPosts();
3. }, [location]);
4.
DevToMaterial

Tags (#javascript, #react, etc) work the exactly the same way. Simply pass the tag name into the query param tag. For example, this will fetch posts with the tag javascript.

jsx
1.https://dev.to/api/articles/?tag=javascript

Although we cannot implement a real search with the API currently (as far as I know) we can implement a simple tag search by following the same pattern and passing the input to the tag query param.

jsx
1.const [search, setSearch] = useState(initialState.search);
2.
3.function HandleNavigate(search) {
4. if (!search) return;
5. const link = search ? `?tag=${search}` : "/";
6.
7. history.push(link);
8.}

Rendering Post HTML Cross-Platform

The process for fetching a specific post is similar to fetching a list of posts. Simply pass the postId to the /articles endpoint.

jsx
1.const fetchPost = async () => {
2. setIsLoading(true);
3. const postId = match && match.params && match.params.id;
4.
5. try {
6. const result = await fetch(`https://dev.to/api/articles/${postId}`);
7. const data = await result.json();
8.
9. setPost(data);
10. setHasError(false);
11. setIsLoading(false);
12. } catch (e) {
13. setIsLoading(false);
14. setHasError(true);
15. }
16.};

Displaying the post, however, is more tricky. The Dev.to API provides each post in two formats, html (body_html) and markdown (body_markdown). Although packages exist to render markdown on each platform, I found it difficult to get each post to render correctly on all platforms. Instead we can accomplish this by using the post html.

For web apps, we could use dangerouslySetInnerHtml to render a full post, but obviously this won't work on React Native. Instead we can use an excellent package, react-native-render-html.

First, we need to transform react-native-render-html with Webpack, replace the exclude line in both web/webpack.config.js and electron/webpack.config.js with the following:

jsx
1.test: /.(js|jsx)$/,
2.exclude: /node_modules/(?!(material-bread|react-native-vector-icons|react-native-render-html)/).*/,

Then, pass the post.body_html to the HTML component from react-native-render-html.

jsx
1.// src/Screens/Post.js
2.
3....
4.import HTML from "react-native-render-html";
5....
6.<Layout>
7. <Card style={styles.postCard}>
8. {post && post.cover_image ? (
9. <Image
10. source={{ uri: post && post.cover_image }}
11. style={[ styles.postImage ]}
12. />
13. ) : null}
14.
15. <Heading type={3} text={post && post.title} />
16. <Heading type={5} text={post && post.user && post.user.name} />
17. {post && !isLoading ? (
18. <HTML html={post.body_html} />
19. ) : (
20. <Loader isLoading={isLoading} />
21. )}
22. {hasError ? <Text>Something went wrong fetching the post, please try again</Text> : null}
23. </Card>
24.</Layout>
25....
DevToMaterial

This works great across platforms, however, the post images are extending past the cards. react-native-render-html provides a prop imagesMaxWidth to set the image's max width, but it is not responsive. Unlike other responsive issues, we want the image's width to be determined by the containing Card, not the window width. So instead of using the responsive components we defined above, we need to fall back to use the onLayout prop described previously.

Add the onLayout prop <View> component with a callback function that sets the cardWidth equal to Card. Then set the imagesMaxWidth prop on the HTML component to the cardWidth.

jsx
1.const [cardWidth, setCardWidth] = useState(initialState.cardWidth);
2.const onCardLayout = e => {
3. setCardWidth(e.nativeEvent.layout.width);
4.};
5....
6.<Card style={styles.postCard}>
7. <View onLayout={onCardLayout}>
8. ...
9. {post && !isLoading ? (
10. <HTML html={post.body_html} imagesMaxWidth={cardWidth} />
11. ) : (
12. <Loader isLoading={isLoading} />
13. )}
14. </View>
15.</Card>
16....

Now the post's image will update its width whenever the PostCard width is updated.

Conclusion

DevToMaterial

React Native, along with many other tools, allows us to write one app and render it on many platforms. Although there are certainty aspects that need improvement, like responsiveness and animations, the fact that a small team can reasonably build apps for multiple platforms without expertise in multiple languages and platforms really opens the playing field for solo developers or smaller companies.

Having said that, React Native development can also be quite frustrating. For example, I wasted a few hours in Github issues and StackOverflow trying to get the bundle to load on iOS, react-native bundle hangs during "Loading", and trying to get Xcode and iOS 13 to work correctly, Unknown argument type ‘attribute’ in method. Furthermore, while building Material Bread, I found z-index barely works on Android. These aren't necessarily deal breakers, but spending all this time on problems like these can really stall development.

Despite these issues, all the code in this project is 100% shared on all platforms, only a few components required any logic specific to a platform. Obviously, I didn't cover every part of the app, but feel free to ask or check out the Github Repo to learn more.