diff options
author | Paul Frazee <pfrazee@gmail.com> | 2022-06-09 13:03:25 -0500 |
---|---|---|
committer | Paul Frazee <pfrazee@gmail.com> | 2022-06-09 13:03:25 -0500 |
commit | d6942bffab68ce80d5cb26b42710dd9276f62ded (patch) | |
tree | 309f8d64f95d526d3cae6c00611c93b04f12944e | |
parent | 92ca49ab9a309510a5503a4df6a0ebcfba30f918 (diff) | |
download | voidsky-d6942bffab68ce80d5cb26b42710dd9276f62ded.tar.zst |
Add state management
-rw-r--r-- | .eslintrc.js | 3 | ||||
-rw-r--r-- | .prettierrc.js | 3 | ||||
-rw-r--r-- | README.md | 4 | ||||
-rw-r--r-- | __tests__/App-test.tsx | 14 | ||||
-rw-r--r-- | babel.config.js | 2 | ||||
-rw-r--r-- | index.native.js | 8 | ||||
-rw-r--r-- | metro.config.js | 2 | ||||
-rw-r--r-- | package.json | 3 | ||||
-rw-r--r-- | src/App.native.tsx | 80 | ||||
-rw-r--r-- | src/App.tsx | 112 | ||||
-rw-r--r-- | src/App.web.tsx | 80 | ||||
-rw-r--r-- | src/index.js | 8 | ||||
-rw-r--r-- | src/state/env.ts | 27 | ||||
-rw-r--r-- | src/state/index.ts | 30 | ||||
-rw-r--r-- | src/state/models/root-store.ts | 16 | ||||
-rw-r--r-- | src/state/storage.ts | 52 | ||||
-rw-r--r-- | yarn.lock | 29 |
17 files changed, 340 insertions, 133 deletions
diff --git a/.eslintrc.js b/.eslintrc.js index 4acd8ff80..898ffe6d7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,7 +10,8 @@ module.exports = { '@typescript-eslint/no-shadow': 'off', 'no-shadow': 'off', 'no-undef': 'off', + semi: [2, 'never'], }, }, ], -}; +} diff --git a/.prettierrc.js b/.prettierrc.js index 2b540746a..15ad6f422 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,7 +1,8 @@ module.exports = { + semi: false, arrowParens: 'avoid', bracketSameLine: true, bracketSpacing: false, singleQuote: true, trailingComma: 'all', -}; +} diff --git a/README.md b/README.md index 352fc769f..81f530018 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ Uses: - [React Native](https://reactnative.dev) - [React Native for Web](https://necolas.github.io/react-native-web/) - [React Navigation](https://reactnative.dev/docs/navigation#react-navigation) -- (todo) [MobX](https://mobx.js.org/README.html) and [MobX State Tree](https://mobx-state-tree.js.org/) -- (todo) [Async Storage](https://github.com/react-native-async-storage/async-storage) +- [MobX](https://mobx.js.org/README.html) and [MobX State Tree](https://mobx-state-tree.js.org/) +- [Async Storage](https://github.com/react-native-async-storage/async-storage) ## Build instructions diff --git a/__tests__/App-test.tsx b/__tests__/App-test.tsx index dab45f325..47060512c 100644 --- a/__tests__/App-test.tsx +++ b/__tests__/App-test.tsx @@ -2,15 +2,15 @@ * @format */ -import 'react-native'; -import React from 'react'; -import App from '../src/App'; +import 'react-native' +import React from 'react' +import App from '../src/App' // Note: test renderer must be required after react-native. -import renderer from 'react-test-renderer'; +import renderer from 'react-test-renderer' it('renders correctly', () => { renderer.act(() => { - renderer.create(<App />); - }); -}); + renderer.create(<App />) + }) +}) diff --git a/babel.config.js b/babel.config.js index f842b77fc..cf1f9fbbc 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,3 @@ module.exports = { presets: ['module:metro-react-native-babel-preset'], -}; +} diff --git a/index.native.js b/index.native.js index c39990524..2d2c9ca44 100644 --- a/index.native.js +++ b/index.native.js @@ -2,8 +2,8 @@ * @format */ -import {AppRegistry} from 'react-native'; -import App from './src/App'; -import {name as appName} from './src/app.json'; +import {AppRegistry} from 'react-native' +import App from './src/App' +import {name as appName} from './src/app.json' -AppRegistry.registerComponent(appName, () => App); +AppRegistry.registerComponent(appName, () => App) diff --git a/metro.config.js b/metro.config.js index e91aba937..c81b3ca13 100644 --- a/metro.config.js +++ b/metro.config.js @@ -14,4 +14,4 @@ module.exports = { }, }), }, -}; +} diff --git a/package.json b/package.json index d62186136..346cbcc9c 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,11 @@ "lint": "eslint . --ext .js,.jsx,.ts,.tsx" }, "dependencies": { + "@react-native-async-storage/async-storage": "^1.17.6", "@react-navigation/native": "^6.0.10", "@react-navigation/native-stack": "^6.6.2", + "mobx": "^6.6.0", + "mobx-state-tree": "^5.1.5", "react": "17.0.2", "react-dom": "17.0.2", "react-native": "0.68.2", diff --git a/src/App.native.tsx b/src/App.native.tsx new file mode 100644 index 000000000..40989caf0 --- /dev/null +++ b/src/App.native.tsx @@ -0,0 +1,80 @@ +import React, {useState, useEffect} from 'react' +import { + SafeAreaView, + ScrollView, + StatusBar, + Text, + Button, + useColorScheme, + View, +} from 'react-native' +import {NavigationContainer} from '@react-navigation/native' +import { + createNativeStackNavigator, + NativeStackScreenProps, +} from '@react-navigation/native-stack' +import {RootStore, setupState, RootStoreProvider} from './state' + +type RootStackParamList = { + Home: undefined + Profile: {name: string} +} +const Stack = createNativeStackNavigator() + +const HomeScreen = ({ + navigation, +}: NativeStackScreenProps<RootStackParamList, 'Home'>) => { + const isDarkMode = useColorScheme() === 'dark' + + return ( + <SafeAreaView> + <StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} /> + <ScrollView contentInsetAdjustmentBehavior="automatic"> + <View> + <Text>Native</Text> + <Button + title="Go to Jane's profile" + onPress={() => navigation.navigate('Profile', {name: 'Jane'})} + /> + </View> + </ScrollView> + </SafeAreaView> + ) +} + +const ProfileScreen = ({ + route, +}: NativeStackScreenProps<RootStackParamList, 'Profile'>) => { + return <Text>This is {route.params.name}'s profile</Text> +} + +function App() { + const [rootStore, setRootStore] = useState<RootStore | undefined>(undefined) + + // init + useEffect(() => { + setupState().then(setRootStore) + }, []) + + // show nothing prior to init + if (!rootStore) { + return null + } + + return ( + <RootStoreProvider value={rootStore}> + <NavigationContainer> + <Stack.Navigator> + <Stack.Screen + name="Home" + component={HomeScreen} + options={{title: 'Welcome'}} + /> + <Stack.Screen name="Profile" component={ProfileScreen} /> + </Stack.Navigator> + </NavigationContainer> + </RootStoreProvider> + ) +} + +export default App diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index e0a0241bb..000000000 --- a/src/App.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Sample React Native App - * https://github.com/facebook/react-native - * - * Generated with the TypeScript template - * https://github.com/react-native-community/react-native-template-typescript - * - * @format - */ - -import React from 'react'; -import { - SafeAreaView, - ScrollView, - StatusBar, - StyleSheet, - Text, - Button, - useColorScheme, - View, -} from 'react-native'; -import {NavigationContainer} from '@react-navigation/native'; -import { - createNativeStackNavigator, - NativeStackScreenProps, -} from '@react-navigation/native-stack'; - -type RootStackParamList = { - Home: undefined; - Profile: {name: string}; -}; -const Stack = createNativeStackNavigator(); - -const Section: React.FC<{ - title: string; -}> = ({children, title}) => { - return ( - <View style={styles.sectionContainer}> - <Text style={styles.sectionTitle}>{title}</Text> - <Text style={styles.sectionDescription}>{children}</Text> - </View> - ); -}; - -const HomeScreen = ({ - navigation, -}: NativeStackScreenProps<RootStackParamList, 'Home'>) => { - const isDarkMode = useColorScheme() === 'dark'; - - return ( - <SafeAreaView> - <StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} /> - <ScrollView contentInsetAdjustmentBehavior="automatic"> - <View> - <Section title="Step One"> - Edit <Text style={styles.highlight}>App.tsx</Text> to change this - screen and then come back to see your edits. - <Button - title="Go to Jane's profile" - onPress={() => navigation.navigate('Profile', {name: 'Jane'})} - /> - </Section> - <Section title="Learn More"> - Read the docs to discover what to do next: - </Section> - </View> - </ScrollView> - </SafeAreaView> - ); -}; - -const ProfileScreen = ({ - route, -}: NativeStackScreenProps<RootStackParamList, 'Profile'>) => { - return <Text>This is {route.params.name}'s profile</Text>; -}; - -const App = () => { - return ( - <NavigationContainer> - <Stack.Navigator> - <Stack.Screen - name="Home" - component={HomeScreen} - options={{title: 'Welcome'}} - /> - <Stack.Screen name="Profile" component={ProfileScreen} /> - </Stack.Navigator> - </NavigationContainer> - ); -}; - -const styles = StyleSheet.create({ - sectionContainer: { - marginTop: 32, - paddingHorizontal: 24, - }, - sectionTitle: { - fontSize: 24, - fontWeight: '600', - }, - sectionDescription: { - marginTop: 8, - fontSize: 18, - fontWeight: '400', - }, - highlight: { - fontWeight: '700', - }, -}); - -export default App; diff --git a/src/App.web.tsx b/src/App.web.tsx new file mode 100644 index 000000000..18b15821b --- /dev/null +++ b/src/App.web.tsx @@ -0,0 +1,80 @@ +import React, {useState, useEffect} from 'react' +import { + SafeAreaView, + ScrollView, + StatusBar, + Text, + Button, + useColorScheme, + View, +} from 'react-native' +import {NavigationContainer} from '@react-navigation/native' +import { + createNativeStackNavigator, + NativeStackScreenProps, +} from '@react-navigation/native-stack' +import {RootStore, setupState, RootStoreProvider} from './state' + +type RootStackParamList = { + Home: undefined + Profile: {name: string} +} +const Stack = createNativeStackNavigator() + +const HomeScreen = ({ + navigation, +}: NativeStackScreenProps<RootStackParamList, 'Home'>) => { + const isDarkMode = useColorScheme() === 'dark' + + return ( + <SafeAreaView> + <StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} /> + <ScrollView contentInsetAdjustmentBehavior="automatic"> + <View> + <Text>Web</Text> + <Button + title="Go to Jane's profile" + onPress={() => navigation.navigate('Profile', {name: 'Jane'})} + /> + </View> + </ScrollView> + </SafeAreaView> + ) +} + +const ProfileScreen = ({ + route, +}: NativeStackScreenProps<RootStackParamList, 'Profile'>) => { + return <Text>This is {route.params.name}'s profile</Text> +} + +function App() { + const [rootStore, setRootStore] = useState<RootStore | undefined>(undefined) + + // init + useEffect(() => { + setupState().then(setRootStore) + }, []) + + // show nothing prior to init + if (!rootStore) { + return null + } + + return ( + <RootStoreProvider value={rootStore}> + <NavigationContainer> + <Stack.Navigator> + <Stack.Screen + name="Home" + component={HomeScreen} + options={{title: 'Welcome'}} + /> + <Stack.Screen name="Profile" component={ProfileScreen} /> + </Stack.Navigator> + </NavigationContainer> + </RootStoreProvider> + ) +} + +export default App diff --git a/src/index.js b/src/index.js index 52c30a178..45a06f40a 100644 --- a/src/index.js +++ b/src/index.js @@ -2,11 +2,11 @@ * @format */ -import {AppRegistry} from 'react-native'; -import App from './App'; +import {AppRegistry} from 'react-native' +import App from './App' -AppRegistry.registerComponent('App', () => App); +AppRegistry.registerComponent('App', () => App) AppRegistry.runApplication('App', { rootTag: document.getElementById('root'), -}); +}) diff --git a/src/state/env.ts b/src/state/env.ts new file mode 100644 index 000000000..90a2cab5e --- /dev/null +++ b/src/state/env.ts @@ -0,0 +1,27 @@ +/** + * The environment is a place where services and shared dependencies between + * models live. They are made available to every model via dependency injection. + */ + +import {getEnv, IStateTreeNode} from 'mobx-state-tree' + +export class Environment { + constructor() {} + + async setup() {} +} + +/** + * Extension to the MST models that adds the environment property. + * Usage: + * + * .extend(withEnvironment) + * + */ +export const withEnvironment = (self: IStateTreeNode) => ({ + views: { + get environment() { + return getEnv<Environment>(self) + }, + }, +}) diff --git a/src/state/index.ts b/src/state/index.ts new file mode 100644 index 000000000..7c97ce294 --- /dev/null +++ b/src/state/index.ts @@ -0,0 +1,30 @@ +import {onSnapshot} from 'mobx-state-tree' +import {RootStoreModel, RootStore} from './models/root-store' +import {Environment} from './env' +import * as storage from './storage' + +const ROOT_STATE_STORAGE_KEY = 'root' + +export async function setupState() { + let rootStore: RootStore + let data: any + + const env = new Environment() + try { + data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {} + rootStore = RootStoreModel.create(data, env) + } catch (e) { + console.error('Failed to load state from storage', e) + rootStore = RootStoreModel.create({}, env) + } + + // track changes & save to storage + onSnapshot(rootStore, snapshot => + storage.save(ROOT_STATE_STORAGE_KEY, snapshot), + ) + + return rootStore +} + +export {useStores, RootStoreModel, RootStoreProvider} from './models/root-store' +export type {RootStore} from './models/root-store' diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts new file mode 100644 index 000000000..164dfcced --- /dev/null +++ b/src/state/models/root-store.ts @@ -0,0 +1,16 @@ +/** + * The root store is the base of all modeled state. + */ + +import {Instance, SnapshotOut, types} from 'mobx-state-tree' +import {createContext, useContext} from 'react' + +export const RootStoreModel = types.model('RootStore').props({}) + +export interface RootStore extends Instance<typeof RootStoreModel> {} +export interface RootStoreSnapshot extends SnapshotOut<typeof RootStoreModel> {} + +// react context & hook utilities +const RootStoreContext = createContext<RootStore>({} as RootStore) +export const RootStoreProvider = RootStoreContext.Provider +export const useStores = () => useContext(RootStoreContext) diff --git a/src/state/storage.ts b/src/state/storage.ts new file mode 100644 index 000000000..dc5fb620f --- /dev/null +++ b/src/state/storage.ts @@ -0,0 +1,52 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +export async function loadString(key: string): Promise<string | null> { + try { + return await AsyncStorage.getItem(key) + } catch { + // not sure why this would fail... even reading the RN docs I'm unclear + return null + } +} + +export async function saveString(key: string, value: string): Promise<boolean> { + try { + await AsyncStorage.setItem(key, value) + return true + } catch { + return false + } +} + +export async function load(key: string): Promise<any | null> { + try { + const str = await AsyncStorage.getItem(key) + if (typeof str !== 'string') { + return null + } + return JSON.parse(str) + } catch { + return null + } +} + +export async function save(key: string, value: any): Promise<boolean> { + try { + await AsyncStorage.setItem(key, JSON.stringify(value)) + return true + } catch { + return false + } +} + +export async function remove(key: string): Promise<void> { + try { + await AsyncStorage.removeItem(key) + } catch {} +} + +export async function clear(): Promise<void> { + try { + await AsyncStorage.clear() + } catch {} +} diff --git a/yarn.lock b/yarn.lock index d6892a77e..0001c9378 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1797,6 +1797,13 @@ schema-utils "^3.0.0" source-map "^0.7.3" +"@react-native-async-storage/async-storage@^1.17.6": + version "1.17.6" + resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-1.17.6.tgz#ddb3520d051f71698c8a0e79e8959a7bf6d9f43b" + integrity sha512-XXnoheQI3vQTQmjphdXNLTmtiKZeRqvI8kPQ25X5Eae7nZjdYEEGN+0z8N2qyelbUIQwKgmW0aagJk56q7DyNg== + dependencies: + merge-options "^3.0.4" + "@react-native-community/cli-debugger-ui@^7.0.3": version "7.0.3" resolved "https://registry.yarnpkg.com/@react-native-community/cli-debugger-ui/-/cli-debugger-ui-7.0.3.tgz#3eeeacc5a43513cbcae56e5e965d77726361bcb4" @@ -6634,6 +6641,11 @@ is-obj@^1.0.1: resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" integrity sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg== +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + is-plain-obj@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz#af6f2ea14ac5a646183a5bbdb5baabbc156ad9d7" @@ -8128,6 +8140,13 @@ merge-descriptors@1.0.1: resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== +merge-options@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/merge-options/-/merge-options-3.0.4.tgz#84709c2aa2a4b24c1981f66c179fe5565cc6dbb7" + integrity sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ== + dependencies: + is-plain-obj "^2.1.0" + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -8506,6 +8525,16 @@ mkdirp@^0.5.1, mkdirp@~0.5.1: dependencies: minimist "^1.2.6" +mobx-state-tree@^5.1.5: + version "5.1.5" + resolved "https://registry.yarnpkg.com/mobx-state-tree/-/mobx-state-tree-5.1.5.tgz#7344d61072705747abb98d23ad21302e38200105" + integrity sha512-jugIic0PYWW+nzzYfp4RUy9dec002Z778OC6KzoOyBHnqxupK9iPCsUJYkHjmNRHjZ8E4Z7qQpsKV3At/ntGVw== + +mobx@^6.6.0: + version "6.6.0" + resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.6.0.tgz#617ca1f3b745a781fa89c5eb94a773e3cbeff8ae" + integrity sha512-MNTKevLH/6DShLZcmSL351+JgiJPO56A4GUpoiDQ3/yZ0mAtclNLdHK9q4BcQhibx8/JSDupfTpbX2NZPemlRg== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" |