Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit c557a2d

Browse files
committedMar 16, 2025··
chore: update Expo example app
1 parent f95af76 commit c557a2d

39 files changed

+3160
-3459
lines changed
 

‎examples/publish-ci/expo/.eslintrc.json

-41
This file was deleted.

‎examples/publish-ci/expo/.gitignore

+8
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ node_modules/
88
.expo/
99
dist/
1010
web-build/
11+
expo-env.d.ts
12+
build/
1113

1214
# Native
1315
*.orig.*
@@ -36,3 +38,9 @@ yarn-error.*
3638

3739
# typescript
3840
*.tsbuildinfo
41+
42+
# IDE
43+
.vscode
44+
45+
.yalc/
46+
yalc.lock
+2-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"arrowParens": "avoid",
3-
"bracketSameLine": true,
4-
"singleQuote": true,
5-
"trailingComma": "all"
3+
"semi": false,
4+
"singleQuote": true
65
}

‎examples/publish-ci/expo/App.tsx

+10-83
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,10 @@
1-
import type { FC } from 'react';
2-
import {
3-
SafeAreaView,
4-
ScrollView,
5-
StatusBar,
6-
StyleSheet,
7-
Text,
8-
View,
9-
useColorScheme,
10-
} from 'react-native';
11-
import { Provider } from 'react-redux';
12-
import { store } from './src/app/store';
13-
import { Counter } from './src/features/counter/Counter';
14-
15-
import {
16-
DebugInstructions,
17-
HermesBadge,
18-
LearnMoreLinks,
19-
ReloadInstructions,
20-
} from 'react-native/Libraries/NewAppScreen';
21-
import { Header } from './src/components/Header';
22-
import { LearnReduxLinks } from './src/components/LearnReduxLinks';
23-
import { Section } from './src/components/Section';
24-
import { TypedColors } from './src/constants/TypedColors';
25-
26-
export const App: FC = () => {
27-
const isDarkMode = useColorScheme() === 'dark';
28-
29-
const backgroundStyle = {
30-
backgroundColor: isDarkMode ? TypedColors.darker : TypedColors.lighter,
31-
};
32-
33-
return (
34-
<Provider store={store}>
35-
<SafeAreaView style={backgroundStyle}>
36-
<StatusBar
37-
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
38-
backgroundColor={backgroundStyle.backgroundColor}
39-
/>
40-
<ScrollView
41-
contentInsetAdjustmentBehavior="automatic"
42-
style={backgroundStyle}>
43-
<Header />
44-
<HermesBadge />
45-
<View
46-
style={{
47-
backgroundColor: isDarkMode
48-
? TypedColors.black
49-
: TypedColors.white,
50-
}}>
51-
<Counter />
52-
<Section title="Step One">
53-
Edit <Text style={styles.highlight}>App.tsx</Text> to change this
54-
screen and then come back to see your edits.
55-
</Section>
56-
<Section title="See Your Changes">
57-
<ReloadInstructions />
58-
</Section>
59-
<Section title="Debug">
60-
<DebugInstructions />
61-
</Section>
62-
<Section title="Learn More Redux">
63-
Discover what to do next with Redux:
64-
</Section>
65-
<LearnReduxLinks />
66-
<Section title="Learn More React Native">
67-
Read the docs to discover what to do next:
68-
</Section>
69-
<LearnMoreLinks />
70-
</View>
71-
</ScrollView>
72-
</SafeAreaView>
73-
</Provider>
74-
);
75-
};
76-
77-
const styles = StyleSheet.create({
78-
highlight: {
79-
fontWeight: '700',
80-
},
81-
});
82-
83-
export default App;
1+
import type { JSX } from 'react'
2+
import { Provider } from 'react-redux'
3+
import { store } from './src/app/store'
4+
import { Main } from './src/Main'
5+
6+
export const App = (): JSX.Element => (
7+
<Provider store={store}>
8+
<Main />
9+
</Provider>
10+
)

‎examples/publish-ci/expo/__tests__/App.test.tsx

-9
This file was deleted.

‎examples/publish-ci/expo/__tests__/counterSlice.test.ts

-37
This file was deleted.

‎examples/publish-ci/expo/app.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
"orientation": "portrait",
77
"icon": "./assets/icon.png",
88
"userInterfaceStyle": "light",
9+
"newArchEnabled": true,
910
"splash": {
10-
"image": "./assets/splash.png",
11+
"image": "./assets/splash-icon.png",
1112
"resizeMode": "contain",
1213
"backgroundColor": "#ffffff"
1314
},
14-
"assetBundlePatterns": ["**/*"],
1515
"ios": {
1616
"supportsTablet": true
1717
},
17.1 KB
Loading
-46.2 KB
Binary file not shown.
+19-6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
/** @type {import('@babel/core').ConfigFunction} */
2-
module.exports = api => {
3-
api.cache.forever();
1+
/** @import { ConfigFunction } from "@babel/core" */
2+
/** @import { BabelPresetExpoOptions } from "babel-preset-expo" */
3+
4+
/**
5+
* @satisfies {ConfigFunction}
6+
*/
7+
const config = api => {
8+
api.cache.forever()
9+
410
return {
5-
presets: ['babel-preset-expo'],
6-
};
7-
};
11+
presets: [
12+
/**
13+
* @satisfies {['babel-preset-expo', BabelPresetExpoOptions?]}
14+
*/
15+
(['babel-preset-expo']),
16+
],
17+
}
18+
}
19+
20+
module.exports = config
+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import js from '@eslint/js'
2+
import prettierConfig from 'eslint-config-prettier/flat'
3+
import jestPlugin from 'eslint-plugin-jest'
4+
import reactPlugin from 'eslint-plugin-react'
5+
import reactHooksPlugin from 'eslint-plugin-react-hooks'
6+
import globals from 'globals'
7+
import { config, configs } from 'typescript-eslint'
8+
9+
const eslintConfig = config(
10+
{
11+
name: 'global-ignores',
12+
ignores: [
13+
'**/*.snap',
14+
'**/dist/',
15+
'**/.yalc/',
16+
'**/build/',
17+
'**/temp/',
18+
'**/.temp/',
19+
'**/.tmp/',
20+
'**/.yarn/',
21+
'**/coverage/',
22+
],
23+
},
24+
{
25+
name: `${js.meta.name}/recommended`,
26+
...js.configs.recommended,
27+
},
28+
configs.strictTypeChecked,
29+
configs.stylisticTypeChecked,
30+
{
31+
name: `${jestPlugin.meta.name}/recommended`,
32+
...jestPlugin.configs['flat/recommended'],
33+
},
34+
{
35+
name: 'eslint-plugin-react/jsx-runtime',
36+
...reactPlugin.configs.flat['jsx-runtime'],
37+
},
38+
reactHooksPlugin.configs['recommended-latest'],
39+
{
40+
name: 'main',
41+
linterOptions: {
42+
reportUnusedDisableDirectives: 2,
43+
},
44+
languageOptions: {
45+
ecmaVersion: 2020,
46+
globals: globals.node,
47+
parserOptions: {
48+
projectService: true,
49+
tsconfigRootDir: import.meta.dirname,
50+
},
51+
},
52+
rules: {
53+
'no-undef': [0],
54+
'no-restricted-imports': [
55+
2,
56+
{
57+
paths: [
58+
{
59+
name: 'react-redux',
60+
importNames: ['useSelector', 'useStore', 'useDispatch'],
61+
message:
62+
'Please use pre-typed versions from `src/app/hooks.ts` instead.',
63+
},
64+
],
65+
},
66+
],
67+
'@typescript-eslint/consistent-type-definitions': [2, 'type'],
68+
'@typescript-eslint/consistent-type-imports': [
69+
2,
70+
{
71+
prefer: 'type-imports',
72+
fixStyle: 'separate-type-imports',
73+
disallowTypeAnnotations: true,
74+
},
75+
],
76+
},
77+
},
78+
{
79+
name: 'commonjs',
80+
files: ['metro.config.js'],
81+
languageOptions: {
82+
sourceType: 'commonjs',
83+
},
84+
rules: {
85+
'@typescript-eslint/no-require-imports': [
86+
0,
87+
[{ allow: [], allowAsImport: false }],
88+
],
89+
},
90+
},
91+
92+
prettierConfig,
93+
)
94+
95+
export default eslintConfig

‎examples/publish-ci/expo/globals.d.ts

+2-11
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,4 @@
11
declare module '*.gif' {
2-
const logo: number;
3-
export default logo;
4-
}
5-
6-
declare module 'react-native/Libraries/NewAppScreen' {
7-
import type { FC } from 'react';
8-
export const HermesBadge: FC;
9-
}
10-
11-
declare module 'react-native/Libraries/Core/Devtools/openURLInBrowser' {
12-
export default function openURLInBrowser(url: string): void;
2+
const logo: number
3+
export default logo
134
}

‎examples/publish-ci/expo/index.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { registerRootComponent } from 'expo'
2+
import { App } from './App'
3+
4+
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
5+
// It also ensures that whether you load the app in Expo Go or in a native build,
6+
// the environment is set up appropriately
7+
registerRootComponent(App)
+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
import '@testing-library/react-native/extend-expect';
1+
import '@testing-library/react-native'
+29-6
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,33 @@
1-
import type { Config } from 'jest';
1+
import type { Config } from 'jest'
22

33
const config: Config = {
44
preset: 'jest-expo',
5-
testEnvironment: 'node',
6-
setupFilesAfterEnv: ['./jest-setup.ts'],
7-
fakeTimers: { enableGlobally: true },
8-
};
5+
/**
6+
* Without this we will get the following error:
7+
* `SyntaxError: Cannot use import statement outside a module`
8+
*/
9+
transformIgnorePatterns: [
10+
'node_modules/(?!((jest-)?react-native|...|react-redux))',
11+
],
12+
/**
13+
* React Native's `jest` preset includes a
14+
* [polyfill for `window`](https://github.com/facebook/react-native/blob/acb634bc9662c1103bc7c8ca83cfdc62516d0060/packages/react-native/jest/setup.js#L61-L66).
15+
* This polyfill causes React-Redux to use `useEffect`
16+
* instead of `useLayoutEffect` for the `useIsomorphicLayoutEffect` hook.
17+
* As a result, nested component updates may not be properly batched
18+
* when using the `connect` API, leading to potential issues.
19+
*/
20+
globals: {
21+
window: undefined,
22+
navigator: {
23+
product: 'ReactNative',
24+
},
25+
},
26+
setupFilesAfterEnv: ['<rootDir>/jest-setup.ts'],
27+
fakeTimers: {
28+
enableGlobally: true,
29+
advanceTimers: true,
30+
},
31+
}
932

10-
export default config;
33+
export default config
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { getDefaultConfig } from 'expo/metro-config'
2+
import type { MetroConfig } from 'metro-config'
3+
import { mergeConfig } from 'metro-config'
4+
5+
const config: MetroConfig = mergeConfig(getDefaultConfig(__dirname), {
6+
resolver: {
7+
unstable_enablePackageExports: true,
8+
},
9+
})
10+
11+
export { config }
+3-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
// Learn more https://docs.expo.io/guides/customizing-metro
2-
const { getDefaultConfig } = require('expo/metro-config');
1+
require('ts-node/register')
32

4-
/** @type {import('expo/metro-config').MetroConfig} */
5-
const config = getDefaultConfig(__dirname);
3+
const { config } = require('./metro.base.config.ts')
64

7-
module.exports = config;
5+
module.exports = config

‎examples/publish-ci/expo/package.json

+35-27
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,53 @@
11
{
22
"name": "expo-template-redux-typescript",
33
"version": "1.0.0",
4-
"main": "node_modules/expo/AppEntry.js",
4+
"main": "index.ts",
55
"scripts": {
6-
"build": "react-native bundle --entry-file App.tsx --bundle-output app.bundle",
7-
"start": "expo start",
86
"android": "expo run:android",
7+
"build": "expo prebuild -p android && react-native bundle --entry-file index.ts --bundle-output build/bundle.js --platform android --assets-dest build/assets",
8+
"format:check": "prettier --check .",
9+
"format": "prettier --write .",
910
"ios": "expo run:ios",
10-
"web": "expo start --web",
11-
"lint": "eslint .",
1211
"lint:fix": "eslint --fix .",
13-
"format": "prettier --write \"./**/*.{js,ts,tsx}\"",
12+
"lint": "eslint .",
13+
"start": "expo start",
1414
"test": "jest",
15-
"type-check": "tsc --noEmit"
15+
"type-check": "tsc --noEmit",
16+
"web": "expo start --web"
1617
},
1718
"dependencies": {
18-
"@reduxjs/toolkit": "^2.0.1",
19-
"expo": "~49.0.15",
20-
"expo-splash-screen": "~0.20.5",
21-
"expo-status-bar": "~1.6.0",
22-
"react": "18.2.0",
23-
"react-native": "0.72.6",
24-
"react-redux": "^9.0.4"
19+
"@reduxjs/toolkit": "^2.6.1",
20+
"expo": "~52.0.39",
21+
"expo-status-bar": "~2.0.1",
22+
"react": "^19.0.0",
23+
"react-native": "0.78.0",
24+
"react-redux": "^9.2.0"
2525
},
2626
"devDependencies": {
27-
"@babel/core": "^7.20.0",
28-
"@react-native/eslint-config": "^0.74.0",
29-
"@testing-library/react-native": "^12.4.1",
27+
"@babel/core": "^7.26.10",
28+
"@eslint/js": "^9.22.0",
29+
"@react-native-community/cli": "^18.0.0",
30+
"@react-native-community/cli-platform-android": "^17.0.0",
31+
"@react-native-community/cli-platform-ios": "^17.0.0",
32+
"@testing-library/react-native": "^13.2.0",
3033
"@types/babel__core": "^7.20.5",
31-
"@types/jest": "^29.5.11",
32-
"@types/react": "~18.2.14",
33-
"@types/react-test-renderer": "^18.0.7",
34-
"@typescript-eslint/eslint-plugin": "^6.14.0",
35-
"@typescript-eslint/parser": "^6.14.0",
36-
"eslint": "^8.56.0",
37-
"eslint-plugin-prettier": "^5.0.1",
34+
"@types/jest": "^29.5.14",
35+
"@types/node": "^22.13.10",
36+
"@types/react": "^19.0.10",
37+
"@types/react-test-renderer": "^19.0.0",
38+
"eslint": "^9.22.0",
39+
"eslint-config-prettier": "^10.1.1",
40+
"eslint-plugin-jest": "^28.11.0",
41+
"eslint-plugin-react": "^7.37.4",
42+
"eslint-plugin-react-hooks": "^5.2.0",
43+
"globals": "^16.0.0",
3844
"jest": "^29.7.0",
39-
"jest-expo": "^49.0.0",
40-
"prettier": "^3.2.5",
45+
"jest-expo": "^52.0.6",
46+
"prettier": "^3.5.3",
47+
"react-test-renderer": "^19.0.0",
4148
"ts-node": "^10.9.2",
42-
"typescript": "^5.8.2"
49+
"typescript": "^5.8.2",
50+
"typescript-eslint": "^8.26.1"
4351
},
4452
"private": true
4553
}
+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { act, screen } from '@testing-library/react-native'
2+
import { Main } from './Main'
3+
import { renderWithProviders } from './utils/test-utils'
4+
5+
test('Main should have correct initial render', () => {
6+
renderWithProviders(<Main />)
7+
8+
const countLabel = screen.getByLabelText('Count')
9+
10+
const incrementValueInput = screen.getByLabelText('Set increment amount')
11+
12+
// The app should be rendered correctly
13+
expect(screen.getByText(/learn more redux/i)).toBeOnTheScreen()
14+
15+
// Initial state: count should be 0, incrementValue should be 2
16+
expect(countLabel).toHaveTextContent('0')
17+
expect(incrementValueInput).toHaveDisplayValue('2')
18+
})
19+
20+
test('Increment value and Decrement value should work as expected', async () => {
21+
const { user } = renderWithProviders(<Main />)
22+
23+
const countLabel = screen.getByLabelText('Count')
24+
25+
const incrementValueButton = screen.getByLabelText('Increment value')
26+
27+
const decrementValueButton = screen.getByLabelText('Decrement value')
28+
29+
// Click on "+" => Count should be 1
30+
await user.press(incrementValueButton)
31+
expect(countLabel).toHaveTextContent('1')
32+
33+
// Click on "-" => Count should be 0
34+
await user.press(decrementValueButton)
35+
expect(countLabel).toHaveTextContent('0')
36+
})
37+
38+
test('Add Amount should work as expected', async () => {
39+
const { user } = renderWithProviders(<Main />)
40+
41+
const countLabel = screen.getByLabelText('Count')
42+
43+
const incrementValueInput = screen.getByLabelText('Set increment amount')
44+
45+
const addAmountButton = screen.getByText('Add Amount')
46+
47+
// "Add Amount" button is clicked => Count should be 2
48+
await user.press(addAmountButton)
49+
expect(countLabel).toHaveTextContent('2')
50+
51+
// incrementValue is 2, click on "Add Amount" => Count should be 4
52+
await user.clear(incrementValueInput)
53+
await user.type(incrementValueInput, '2')
54+
await user.press(addAmountButton)
55+
expect(countLabel).toHaveTextContent('4')
56+
57+
// [Negative number] incrementValue is -1, click on "Add Amount" => Count should be 3
58+
await user.clear(incrementValueInput)
59+
await user.type(incrementValueInput, '-1')
60+
await user.press(addAmountButton)
61+
expect(countLabel).toHaveTextContent('3')
62+
})
63+
64+
it('Add Async should work as expected', async () => {
65+
const { user } = renderWithProviders(<Main />)
66+
67+
const addAsyncButton = screen.getByText('Add Async')
68+
69+
const countLabel = screen.getByLabelText('Count')
70+
71+
const incrementValueInput = screen.getByLabelText('Set increment amount')
72+
73+
// "Add Async" button is clicked => Count should be 2
74+
await user.press(addAsyncButton)
75+
76+
await act(async () => {
77+
await jest.advanceTimersByTimeAsync(500)
78+
})
79+
80+
expect(countLabel).toHaveTextContent('2')
81+
82+
// incrementValue is 2, click on "Add Async" => Count should be 4
83+
await user.clear(incrementValueInput)
84+
await user.type(incrementValueInput, '2')
85+
86+
await user.press(addAsyncButton)
87+
88+
await act(async () => {
89+
await jest.advanceTimersByTimeAsync(500)
90+
})
91+
92+
expect(countLabel).toHaveTextContent('4')
93+
94+
// [Negative number] incrementValue is -1, click on "Add Async" => Count should be 3
95+
await user.clear(incrementValueInput)
96+
await user.type(incrementValueInput, '-1')
97+
await user.press(addAsyncButton)
98+
99+
await act(async () => {
100+
await jest.advanceTimersByTimeAsync(500)
101+
})
102+
103+
expect(countLabel).toHaveTextContent('3')
104+
})
105+
106+
test('Add If Odd should work as expected', async () => {
107+
const { user } = renderWithProviders(<Main />)
108+
109+
const countLabel = screen.getByLabelText('Count')
110+
111+
const addIfOddButton = screen.getByText('Add If Odd')
112+
113+
const incrementValueInput = screen.getByLabelText('Set increment amount')
114+
115+
const incrementValueButton = screen.getByLabelText('Increment value')
116+
117+
// "Add If Odd" button is clicked => Count should stay 0
118+
await user.press(addIfOddButton)
119+
expect(countLabel).toHaveTextContent('0')
120+
121+
// Click on "+" => Count should be updated to 1
122+
await user.press(incrementValueButton)
123+
expect(countLabel).toHaveTextContent('1')
124+
125+
// "Add If Odd" button is clicked => Count should be updated to 3
126+
await user.press(addIfOddButton)
127+
expect(countLabel).toHaveTextContent('3')
128+
129+
// incrementValue is 1, click on "Add If Odd" => Count should be updated to 4
130+
await user.clear(incrementValueInput)
131+
await user.type(incrementValueInput, '1')
132+
await user.press(addIfOddButton)
133+
expect(countLabel).toHaveTextContent('4')
134+
135+
// click on "Add If Odd" => Count should stay 4
136+
await user.clear(incrementValueInput)
137+
await user.type(incrementValueInput, '-1')
138+
await user.press(addIfOddButton)
139+
expect(countLabel).toHaveTextContent('4')
140+
})

‎examples/publish-ci/expo/src/Main.tsx

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { StatusBar } from 'expo-status-bar'
2+
import type { JSX } from 'react'
3+
import {
4+
Platform,
5+
SafeAreaView,
6+
ScrollView,
7+
StyleSheet,
8+
Text,
9+
View,
10+
useColorScheme,
11+
} from 'react-native'
12+
import { Header } from './components/Header'
13+
import { LearnMoreLinks, LearnReduxLinks } from './components/LearnReduxLinks'
14+
import { Section } from './components/Section'
15+
import { Colors } from './constants/Colors'
16+
import { Counter } from './features/counter/Counter'
17+
import { Quotes } from './features/quotes/Quotes'
18+
19+
const ReloadInstructions = Platform.select({
20+
ios: () => (
21+
<Text>
22+
Press <Text style={styles.highlight}>Cmd + R</Text> in the simulator to
23+
reload your app's code.
24+
</Text>
25+
),
26+
default: () => (
27+
<Text>
28+
Double tap <Text style={styles.highlight}>R</Text> on your keyboard to
29+
reload your app's code.
30+
</Text>
31+
),
32+
})
33+
34+
const DebugInstructions = Platform.select({
35+
ios: () => (
36+
<Text>
37+
Press <Text style={styles.highlight}>Cmd + D</Text> in the simulator or{' '}
38+
<Text style={styles.highlight}>Shake</Text> your device to open the Dev
39+
Menu.
40+
</Text>
41+
),
42+
default: () => (
43+
<Text>
44+
Press <Text style={styles.highlight}>Cmd or Ctrl + M</Text> or{' '}
45+
<Text style={styles.highlight}>Shake</Text> your device to open the Dev
46+
Menu.
47+
</Text>
48+
),
49+
})
50+
51+
export const Main = (): JSX.Element => {
52+
const isDarkMode = useColorScheme() === 'dark'
53+
54+
const backgroundStyle = {
55+
backgroundColor: isDarkMode ? Colors.darker : Colors.lighter,
56+
}
57+
58+
return (
59+
<SafeAreaView style={[backgroundStyle, styles.safeAreaView]}>
60+
<StatusBar
61+
style={isDarkMode ? 'light' : 'dark'}
62+
backgroundColor={backgroundStyle.backgroundColor}
63+
/>
64+
<ScrollView
65+
contentInsetAdjustmentBehavior="automatic"
66+
style={backgroundStyle}
67+
>
68+
<Header />
69+
<View
70+
style={{
71+
backgroundColor: isDarkMode ? Colors.black : Colors.white,
72+
}}
73+
>
74+
<Counter />
75+
<Quotes />
76+
<Section title="Step One">
77+
Edit <Text style={styles.highlight}>App.tsx</Text> to change this
78+
screen and then come back to see your edits.
79+
</Section>
80+
<Section title="See Your Changes">
81+
<ReloadInstructions />
82+
</Section>
83+
<Section title="Debug">
84+
<DebugInstructions />
85+
</Section>
86+
<Section title="Learn More Redux">
87+
Discover what to do next with Redux:
88+
</Section>
89+
<LearnReduxLinks />
90+
<Section title="Learn More React Native">
91+
Read the docs to discover what to do next:
92+
</Section>
93+
<LearnMoreLinks />
94+
</View>
95+
</ScrollView>
96+
</SafeAreaView>
97+
)
98+
}
99+
100+
const styles = StyleSheet.create({
101+
highlight: {
102+
fontWeight: '700',
103+
},
104+
safeAreaView: {
105+
flex: 1,
106+
},
107+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { asyncThunkCreator, buildCreateSlice } from '@reduxjs/toolkit'
2+
3+
// `buildCreateSlice` allows us to create a slice with async thunks.
4+
export const createAppSlice = buildCreateSlice({
5+
creators: { asyncThunk: asyncThunkCreator },
6+
})
+41-24
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,46 @@
1-
import { useEffect, useRef } from 'react';
2-
import { Animated, useWindowDimensions } from 'react-native';
3-
import type { TypedUseSelectorHook } from 'react-redux';
4-
import { useDispatch, useSelector } from 'react-redux';
5-
import type { AppDispatch, RootState } from './store';
1+
// This file serves as a central hub for re-exporting pre-typed Redux hooks.
2+
// These imports are restricted elsewhere to ensure consistent
3+
// usage of typed hooks throughout the application.
4+
// We disable the ESLint rule here because this is the designated place
5+
// for importing and re-exporting the typed versions of hooks.
6+
/* eslint-disable no-restricted-imports */
7+
import { useEffect, useRef } from 'react'
8+
import { Animated, useWindowDimensions } from 'react-native'
9+
import { useDispatch, useSelector } from 'react-redux'
10+
import type { AppDispatch, RootState } from './store'
611

712
// Use throughout your app instead of plain `useDispatch` and `useSelector`
8-
export const useAppDispatch: () => AppDispatch = useDispatch;
9-
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
10-
11-
export const useViewportUnits = () => {
12-
const { width, height } = useWindowDimensions();
13-
14-
const vh = height / 100;
15-
const vw = width / 100;
16-
17-
return { vh, vw };
18-
};
19-
20-
export const useBounceAnimation = (value = 10) => {
21-
const bounce = useRef(new Animated.Value(0)).current;
13+
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
14+
export const useAppSelector = useSelector.withTypes<RootState>()
15+
16+
/**
17+
* Custom React hook for calculating viewport units
18+
* based on the current window dimensions.
19+
*
20+
* @returns An object containing the calculated viewport heigh and width values.
21+
*/
22+
export const useViewportUnits = (): { vh: number; vw: number } => {
23+
const { width, height } = useWindowDimensions()
24+
25+
const vh = height / 100
26+
const vw = width / 100
27+
28+
return { vh, vw }
29+
}
30+
31+
/**
32+
* Custom React hook for creating a bounce animation effect.
33+
*
34+
* @param value - The maximum height to which the object should bounce. Defaults to 10 if not provided.
35+
* @returns The `Animated.Value` object that can be used to drive animations.
36+
*/
37+
export const useBounceAnimation = (value = 10): Animated.Value => {
38+
const bounce = useRef(new Animated.Value(0)).current
2239

2340
bounce.interpolate({
2441
inputRange: [-300, -100, 0, 100, 101],
2542
outputRange: [300, 0, 1, 0, 0],
26-
});
43+
})
2744

2845
useEffect(() => {
2946
Animated.loop(
@@ -39,8 +56,8 @@ export const useBounceAnimation = (value = 10) => {
3956
useNativeDriver: true,
4057
}),
4158
]),
42-
).start();
43-
}, [bounce, value]);
59+
).start()
60+
}, [bounce, value])
4461

45-
return bounce;
46-
};
62+
return bounce
63+
}
+37-15
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,42 @@
1-
import type { Action, ThunkAction } from '@reduxjs/toolkit';
2-
import { configureStore } from '@reduxjs/toolkit';
3-
import { counterSlice } from '../features/counter/counterSlice';
1+
import type { Action, ThunkAction } from '@reduxjs/toolkit'
2+
import { combineSlices, configureStore } from '@reduxjs/toolkit'
3+
import { setupListeners } from '@reduxjs/toolkit/query'
4+
import { counterSlice } from '../features/counter/counterSlice'
5+
import { quotesApiSlice } from '../features/quotes/quotesApiSlice'
46

5-
export const store = configureStore({
6-
reducer: {
7-
[counterSlice.reducerPath]: counterSlice.reducer,
8-
},
9-
});
7+
// `combineSlices` automatically combines the reducers using
8+
// their `reducerPath`s, therefore we no longer need to call `combineReducers`.
9+
const rootReducer = combineSlices(counterSlice, quotesApiSlice)
10+
// Infer the `RootState` type from the root reducer
11+
export type RootState = ReturnType<typeof rootReducer>
1012

11-
// Infer the `RootState` and `AppDispatch` types from the store itself
12-
export type RootState = ReturnType<typeof store.getState>;
13-
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
14-
export type AppDispatch = typeof store.dispatch;
15-
export type AppThunk<ReturnType = void> = ThunkAction<
16-
ReturnType,
13+
// The store setup is wrapped in `makeStore` to allow reuse
14+
// when setting up tests that need the same store config
15+
export const makeStore = (preloadedState?: Partial<RootState>) => {
16+
const store = configureStore({
17+
reducer: rootReducer,
18+
// Adding the api middleware enables caching, invalidation, polling,
19+
// and other useful features of `rtk-query`.
20+
middleware: getDefaultMiddleware => {
21+
return getDefaultMiddleware().concat(quotesApiSlice.middleware)
22+
},
23+
preloadedState,
24+
})
25+
// configure listeners using the provided defaults
26+
// optional, but required for `refetchOnFocus`/`refetchOnReconnect` behaviors
27+
setupListeners(store.dispatch)
28+
return store
29+
}
30+
31+
export const store = makeStore()
32+
33+
// Infer the type of `store`
34+
export type AppStore = typeof store
35+
// Infer the `AppDispatch` type from the store itself
36+
export type AppDispatch = AppStore['dispatch']
37+
export type AppThunk<ThunkReturnType = void> = ThunkAction<
38+
ThunkReturnType,
1739
RootState,
1840
unknown,
1941
Action
20-
>;
42+
>
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,56 @@
1-
import type { FC, PropsWithChildren } from 'react';
2-
import { useRef } from 'react';
1+
import type { JSX, PropsWithChildren } from 'react'
2+
import { useRef } from 'react'
33
import type {
44
GestureResponderEvent,
55
PressableProps,
66
ViewStyle,
7-
} from 'react-native';
8-
import { Animated, Pressable, StyleSheet, View } from 'react-native';
7+
} from 'react-native'
8+
import { Animated, Pressable, StyleSheet, View } from 'react-native'
99

10-
type AsyncButtonProps = PressableProps & PropsWithChildren;
10+
type AsyncButtonProps = PressableProps & PropsWithChildren
1111

12-
export const AsyncButton: FC<AsyncButtonProps> = ({
12+
export const AsyncButton = ({
1313
onPress,
1414
style,
1515
children,
1616
...restProps
17-
}) => {
18-
const progress = useRef(new Animated.Value(0)).current;
19-
const opacity = useRef(new Animated.Value(1)).current;
17+
}: AsyncButtonProps): JSX.Element => {
18+
const progress = useRef(new Animated.Value(0)).current
19+
const opacity = useRef(new Animated.Value(1)).current
2020

2121
const _onPress = (e: GestureResponderEvent) => {
22-
progress.setValue(0);
23-
opacity.setValue(1);
22+
progress.setValue(0)
23+
opacity.setValue(1)
2424

25-
onPress?.(e);
25+
onPress?.(e)
2626

27-
// TODO: Maybe change to Animated.sequence
2827
Animated.timing(progress, {
2928
toValue: 1,
3029
duration: 1000,
3130
useNativeDriver: false,
3231
}).start(({ finished }) => {
3332
if (!finished) {
34-
return;
33+
return
3534
}
3635

3736
Animated.timing(opacity, {
3837
toValue: 0,
3938
duration: 200,
4039
useNativeDriver: false,
41-
}).start();
42-
});
43-
};
40+
}).start()
41+
})
42+
}
4443

4544
const progressInterpolate = progress.interpolate({
4645
inputRange: [0, 1],
4746
outputRange: ['0%', '100%'],
4847
extrapolate: 'clamp',
49-
});
48+
})
5049

5150
const progressStyle: Animated.WithAnimatedObject<ViewStyle> = {
5251
width: progressInterpolate,
5352
opacity,
54-
};
53+
}
5554

5655
return (
5756
<Pressable style={style} onPress={_onPress} {...restProps}>
@@ -60,8 +59,8 @@ export const AsyncButton: FC<AsyncButtonProps> = ({
6059
</View>
6160
{children}
6261
</Pressable>
63-
);
64-
};
62+
)
63+
}
6564

6665
const styles = StyleSheet.create({
6766
progress: {
@@ -71,4 +70,4 @@ const styles = StyleSheet.create({
7170
left: 0,
7271
backgroundColor: 'rgba(112,76,182, 0.15)',
7372
},
74-
});
73+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { JSX } from 'react'
2+
import { useCallback } from 'react'
3+
import type { TouchableOpacityProps } from 'react-native'
4+
import { Alert, Linking, TouchableOpacity } from 'react-native'
5+
6+
type ExternalLinkProps = TouchableOpacityProps & {
7+
url: string
8+
}
9+
10+
export const ExternalLink = ({
11+
url,
12+
...touchableOpacityProps
13+
}: ExternalLinkProps): JSX.Element => {
14+
const onPress = useCallback(async () => {
15+
const supported = await Linking.canOpenURL(url)
16+
17+
if (supported) {
18+
await Linking.openURL(url)
19+
} else {
20+
Alert.alert(`Don't know how to open this URL: ${url}`)
21+
}
22+
}, [url])
23+
24+
return (
25+
<TouchableOpacity
26+
{...touchableOpacityProps}
27+
accessibilityRole="button"
28+
onPress={() => {
29+
void onPress()
30+
}}
31+
/>
32+
)
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { JSX } from 'react'
2+
import { Fragment } from 'react'
3+
import { StyleSheet, Text, useColorScheme, View } from 'react-native'
4+
import { Colors } from '../constants/Colors'
5+
import { ExternalLink } from './ExternalLink'
6+
7+
export type Link = {
8+
id: number
9+
title: string
10+
url: string
11+
description: string
12+
}
13+
14+
type ExternalLinksProps = {
15+
links: Link[]
16+
}
17+
18+
export const ExternalLinks = ({ links }: ExternalLinksProps): JSX.Element => {
19+
const isDarkMode = useColorScheme() === 'dark'
20+
21+
return (
22+
<View style={styles.container}>
23+
{links.map(({ description, id, title, url }) => (
24+
<Fragment key={id}>
25+
<View
26+
style={[
27+
styles.separator,
28+
{ backgroundColor: isDarkMode ? Colors.dark : Colors.light },
29+
]}
30+
/>
31+
<ExternalLink style={styles.linkContainer} url={url}>
32+
<Text style={styles.link}>{title}</Text>
33+
<Text
34+
style={[
35+
styles.description,
36+
{ color: isDarkMode ? Colors.lighter : Colors.dark },
37+
]}
38+
>
39+
{description}
40+
</Text>
41+
</ExternalLink>
42+
</Fragment>
43+
))}
44+
</View>
45+
)
46+
}
47+
48+
const styles = StyleSheet.create({
49+
container: {
50+
marginTop: 32,
51+
paddingHorizontal: 24,
52+
},
53+
linkContainer: {
54+
flexWrap: 'wrap',
55+
flexDirection: 'row',
56+
justifyContent: 'space-between',
57+
alignItems: 'center',
58+
paddingVertical: 8,
59+
},
60+
link: {
61+
flex: 2,
62+
fontSize: 18,
63+
fontWeight: '400',
64+
color: Colors.primary,
65+
},
66+
description: {
67+
flex: 3,
68+
paddingVertical: 16,
69+
fontWeight: '400',
70+
fontSize: 18,
71+
},
72+
separator: {
73+
height: StyleSheet.hairlineWidth,
74+
},
75+
})
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,34 @@
1-
import { Animated, StyleSheet, View, useColorScheme } from 'react-native';
2-
import { useBounceAnimation, useViewportUnits } from '../app/hooks';
3-
import { TypedColors } from '../constants/TypedColors';
4-
import logo from './logo.gif';
1+
import type { JSX } from 'react'
2+
import { Animated, StyleSheet, View, useColorScheme } from 'react-native'
3+
import { useBounceAnimation, useViewportUnits } from '../app/hooks'
4+
import { Colors } from '../constants/Colors'
5+
import logo from './logo.gif'
56

6-
export const Header = () => {
7-
const isDarkMode = useColorScheme() === 'dark';
8-
const { vh } = useViewportUnits();
9-
const bounce = useBounceAnimation();
10-
const height = 40 * vh;
7+
export const Header = (): JSX.Element => {
8+
const isDarkMode = useColorScheme() === 'dark'
9+
const { vh } = useViewportUnits()
10+
const bounce = useBounceAnimation()
11+
const height = 40 * vh
1112

1213
return (
1314
<View
1415
style={[
1516
styles.container,
16-
{ backgroundColor: isDarkMode ? TypedColors.black : TypedColors.white },
17-
]}>
17+
{ backgroundColor: isDarkMode ? Colors.black : Colors.white },
18+
]}
19+
>
1820
<Animated.Image
19-
accessibilityRole={'image'}
21+
accessibilityRole="image"
2022
source={logo}
2123
style={{ height, transform: [{ translateY: bounce }] }}
2224
/>
2325
</View>
24-
);
25-
};
26+
)
27+
}
2628

2729
const styles = StyleSheet.create({
2830
container: {
2931
flexDirection: 'row',
3032
justifyContent: 'center',
3133
},
32-
});
34+
})
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,107 @@
1-
import type { FC } from 'react';
2-
import React from 'react';
3-
import {
4-
StyleSheet,
5-
Text,
6-
TouchableOpacity,
7-
View,
8-
useColorScheme,
9-
} from 'react-native';
10-
import openURLInBrowser from 'react-native/Libraries/Core/Devtools/openURLInBrowser';
11-
import { TypedColors } from '../constants/TypedColors';
1+
import type { JSX } from 'react'
2+
import type { Link } from './ExternalLinks'
3+
import { ExternalLinks } from './ExternalLinks'
124

13-
interface Link {
14-
title: string;
15-
link: string;
16-
description: string;
17-
}
18-
19-
const links: Link[] = [
5+
const reduxLinks: Link[] = [
206
{
7+
id: 1,
218
title: 'React',
22-
link: 'https://reactjs.org/',
9+
url: 'https://reactjs.org',
2310
description: 'JavaScript library for building user interfaces',
2411
},
2512
{
13+
id: 2,
2614
title: 'Redux',
27-
link: 'https://redux.js.org/',
15+
url: 'https://redux.js.org',
2816
description: 'A Predictable State Container for JS Apps',
2917
},
3018
{
19+
id: 3,
3120
title: 'Redux Toolkit',
32-
link: 'https://redux-toolkit.js.org/',
21+
url: 'https://redux-toolkit.js.org',
3322
description:
3423
'The official, opinionated, batteries-included toolset for efficient Redux development',
3524
},
3625
{
26+
id: 4,
3727
title: 'React Redux',
38-
link: 'https://react-redux.js.org',
28+
url: 'https://react-redux.js.org',
3929
description: 'Official React bindings for Redux',
4030
},
41-
];
42-
43-
export const LearnReduxLinks: FC = () => {
44-
const isDarkMode = useColorScheme() === 'dark';
45-
46-
return (
47-
<View style={styles.container}>
48-
{links.map((item, index) => {
49-
return (
50-
<React.Fragment key={index}>
51-
<View
52-
style={[
53-
styles.separator,
54-
{
55-
backgroundColor: isDarkMode
56-
? TypedColors.dark
57-
: TypedColors.light,
58-
},
59-
]}
60-
/>
61-
<TouchableOpacity
62-
accessibilityRole={'button'}
63-
onPress={() => {
64-
openURLInBrowser(item.link);
65-
}}
66-
style={styles.linkContainer}>
67-
<Text style={styles.link}>{item.title}</Text>
68-
<Text
69-
style={[
70-
styles.description,
71-
{ color: isDarkMode ? TypedColors.light : TypedColors.dark },
72-
]}>
73-
{item.description}
74-
</Text>
75-
</TouchableOpacity>
76-
</React.Fragment>
77-
);
78-
})}
79-
</View>
80-
);
81-
};
31+
{
32+
id: 5,
33+
title: 'Reselect',
34+
url: 'https://reselect.js.org',
35+
description: 'A memoized selector library for Redux',
36+
},
37+
]
8238

83-
const styles = StyleSheet.create({
84-
container: {
85-
marginTop: 32,
86-
paddingHorizontal: 24,
39+
const reactNativeLinks: Link[] = [
40+
{
41+
id: 1,
42+
title: 'The Basics',
43+
url: 'https://reactnative.dev/docs/tutorial',
44+
description: 'Explains a Hello World for React Native.',
8745
},
88-
linkContainer: {
89-
flexWrap: 'wrap',
90-
flexDirection: 'row',
91-
justifyContent: 'space-between',
92-
alignItems: 'center',
93-
paddingVertical: 8,
46+
{
47+
id: 2,
48+
title: 'Style',
49+
url: 'https://reactnative.dev/docs/style',
50+
description:
51+
'Covers how to use the prop named style which controls the visuals.',
9452
},
95-
link: {
96-
flex: 2,
97-
fontSize: 18,
98-
fontWeight: '400',
99-
color: TypedColors.primary,
53+
{
54+
id: 3,
55+
title: 'Layout',
56+
url: 'https://reactnative.dev/docs/flexbox',
57+
description: 'React Native uses flexbox for layout, learn how it works.',
10058
},
101-
description: {
102-
flex: 3,
103-
paddingVertical: 16,
104-
fontWeight: '400',
105-
fontSize: 18,
59+
{
60+
id: 4,
61+
title: 'Components',
62+
url: 'https://reactnative.dev/docs/components-and-apis',
63+
description: 'The full list of components and APIs inside React Native.',
10664
},
107-
separator: {
108-
height: 1,
65+
{
66+
id: 5,
67+
title: 'Navigation',
68+
url: 'https://reactnative.dev/docs/navigation',
69+
description:
70+
'How to handle moving between screens inside your application.',
10971
},
110-
});
72+
{
73+
id: 6,
74+
title: 'Networking',
75+
url: 'https://reactnative.dev/docs/network',
76+
description: 'How to use the Fetch API in React Native.',
77+
},
78+
{
79+
id: 7,
80+
title: 'Debugging',
81+
url: 'https://facebook.github.io/react-native/docs/debugging',
82+
description:
83+
'Learn about the tools available to debug and inspect your app.',
84+
},
85+
{
86+
id: 8,
87+
title: 'Help',
88+
url: 'https://facebook.github.io/react-native/help',
89+
description:
90+
'Need more help? There are many other React Native developers who may have the answer.',
91+
},
92+
{
93+
id: 9,
94+
title: 'Follow us',
95+
url: 'https://x.com/reactnative',
96+
description:
97+
'Stay in touch with the community, join in on Q&As and more by following React Native on X.',
98+
},
99+
]
100+
101+
export const LearnMoreLinks = (): JSX.Element => (
102+
<ExternalLinks links={reactNativeLinks} />
103+
)
104+
105+
export const LearnReduxLinks = (): JSX.Element => (
106+
<ExternalLinks links={reduxLinks} />
107+
)
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,35 @@
1-
import type { FC, PropsWithChildren } from 'react';
2-
import { StyleSheet, Text, View, useColorScheme } from 'react-native';
3-
import { TypedColors } from '../constants/TypedColors';
1+
import type { JSX, PropsWithChildren } from 'react'
2+
import { StyleSheet, Text, View, useColorScheme } from 'react-native'
3+
import { Colors } from '../constants/Colors'
44

55
type SectionProps = PropsWithChildren<{
6-
title: string;
7-
}>;
6+
title: string
7+
}>
88

9-
export const Section: FC<SectionProps> = ({ children, title }) => {
10-
const isDarkMode = useColorScheme() === 'dark';
9+
export const Section = ({ children, title }: SectionProps): JSX.Element => {
10+
const isDarkMode = useColorScheme() === 'dark'
1111

1212
return (
1313
<View style={styles.sectionContainer}>
1414
<Text
1515
style={[
1616
styles.sectionTitle,
17-
{ color: isDarkMode ? TypedColors.white : TypedColors.black },
18-
]}>
17+
{ color: isDarkMode ? Colors.white : Colors.black },
18+
]}
19+
>
1920
{title}
2021
</Text>
2122
<Text
2223
style={[
2324
styles.sectionDescription,
24-
{ color: isDarkMode ? TypedColors.light : TypedColors.dark },
25-
]}>
25+
{ color: isDarkMode ? Colors.light : Colors.dark },
26+
]}
27+
>
2628
{children}
2729
</Text>
2830
</View>
29-
);
30-
};
31+
)
32+
}
3133

3234
const styles = StyleSheet.create({
3335
sectionContainer: {
@@ -43,4 +45,4 @@ const styles = StyleSheet.create({
4345
fontSize: 18,
4446
fontWeight: '400',
4547
},
46-
});
48+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
type AllColors = {
2+
primary: string
3+
white: string
4+
lighter: string
5+
light: string
6+
dark: string
7+
darker: string
8+
black: string
9+
}
10+
11+
export const Colors: AllColors = {
12+
light: '#DAE1E7',
13+
lighter: '#F3F3F3',
14+
white: '#FFF',
15+
dark: '#444',
16+
darker: '#222',
17+
black: '#000',
18+
primary: '#1292B4',
19+
}

‎examples/publish-ci/expo/src/constants/TypedColors.ts

-13
This file was deleted.

‎examples/publish-ci/expo/src/features/counter/Counter.tsx

+45-26
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,98 @@
1-
import type { FC } from 'react';
2-
import { useState } from 'react';
1+
import type { JSX } from 'react'
2+
import { useState } from 'react'
33
import {
44
StyleSheet,
55
Text,
66
TextInput,
77
TouchableOpacity,
88
View,
9-
} from 'react-native';
10-
import { useAppDispatch, useAppSelector } from '../../app/hooks';
11-
import { AsyncButton } from '../../components/AsyncButton';
9+
useColorScheme,
10+
} from 'react-native'
11+
import { useAppDispatch, useAppSelector } from '../../app/hooks'
12+
import { AsyncButton } from '../../components/AsyncButton'
13+
import { Colors } from '../../constants/Colors'
1214
import {
1315
decrement,
1416
increment,
1517
incrementAsync,
1618
incrementByAmount,
1719
incrementIfOdd,
1820
selectCount,
19-
} from './counterSlice';
21+
selectStatus,
22+
} from './counterSlice'
2023

21-
export const Counter: FC = () => {
22-
const [incrementAmount, setIncrementAmount] = useState('2');
23-
const count = useAppSelector(selectCount);
24-
const status = useAppSelector(state => state.counter.status);
25-
const dispatch = useAppDispatch();
24+
export const Counter = (): JSX.Element => {
25+
const isDarkMode = useColorScheme() === 'dark'
26+
const textStyle = {
27+
color: isDarkMode ? Colors.light : Colors.dark,
28+
}
2629

27-
const incrementValue = Number(incrementAmount) || 0;
30+
const dispatch = useAppDispatch()
31+
const count = useAppSelector(selectCount)
32+
const status = useAppSelector(selectStatus)
33+
const [incrementAmount, setIncrementAmount] = useState('2')
34+
35+
const incrementValue = Number(incrementAmount) || 0
2836

2937
return (
3038
<View>
3139
<View style={styles.row}>
3240
<TouchableOpacity
3341
style={styles.button}
34-
onPress={() => dispatch(increment())}>
35-
<Text style={styles.buttonText}>+</Text>
42+
aria-label="Decrement value"
43+
onPress={() => dispatch(decrement())}
44+
>
45+
<Text style={styles.buttonText}>-</Text>
3646
</TouchableOpacity>
37-
<Text style={styles.value}>{count}</Text>
47+
<Text aria-label="Count" style={[styles.value, textStyle]}>
48+
{count}
49+
</Text>
3850
<TouchableOpacity
3951
style={styles.button}
40-
onPress={() => dispatch(decrement())}>
41-
<Text style={styles.buttonText}>-</Text>
52+
aria-label="Increment value"
53+
onPress={() => dispatch(increment())}
54+
>
55+
<Text style={styles.buttonText}>+</Text>
4256
</TouchableOpacity>
4357
</View>
4458
<View style={styles.row}>
4559
<TextInput
46-
style={styles.textbox}
60+
aria-label="Set increment amount"
61+
style={[styles.textbox, textStyle]}
4762
value={incrementAmount}
4863
keyboardType="numeric"
4964
onChangeText={setIncrementAmount}
5065
/>
5166
<View>
5267
<TouchableOpacity
5368
style={styles.button}
54-
onPress={() => dispatch(incrementByAmount(incrementValue))}>
69+
onPress={() => dispatch(incrementByAmount(incrementValue))}
70+
>
5571
<Text style={styles.buttonText}>Add Amount</Text>
5672
</TouchableOpacity>
5773
<AsyncButton
74+
aria-label="Async Button"
5875
style={styles.button}
5976
disabled={status !== 'idle'}
6077
onPress={() => {
61-
dispatch(incrementAsync(incrementValue)).catch(console.log);
62-
}}>
78+
void dispatch(incrementAsync(incrementValue))
79+
}}
80+
>
6381
<Text style={styles.buttonText}>Add Async</Text>
6482
</AsyncButton>
6583
<TouchableOpacity
6684
style={styles.button}
6785
onPress={() => {
68-
dispatch(incrementIfOdd(incrementValue));
69-
}}>
86+
dispatch(incrementIfOdd(incrementValue))
87+
}}
88+
>
7089
<Text style={styles.buttonText}>Add If Odd</Text>
7190
</TouchableOpacity>
7291
</View>
7392
</View>
7493
</View>
75-
);
76-
};
94+
)
95+
}
7796

7897
const styles = StyleSheet.create({
7998
row: {
@@ -109,4 +128,4 @@ const styles = StyleSheet.create({
109128
borderWidth: 1,
110129
justifyContent: 'center',
111130
},
112-
});
131+
})
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
// A mock function to mimic making an async request for data
2-
export const fetchCount = (amount = 1) => {
3-
return new Promise<{ data: number }>(resolve =>
2+
export const fetchCount = (amount = 1): Promise<{ data: number }> =>
3+
new Promise<{ data: number }>(resolve =>
44
setTimeout(() => {
5-
resolve({ data: amount });
5+
resolve({ data: amount })
66
}, 500),
7-
);
8-
};
7+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { makeStore } from '../../app/store'
2+
import type { CounterSliceState } from './counterSlice'
3+
import {
4+
counterSlice,
5+
decrement,
6+
increment,
7+
incrementByAmount,
8+
selectCount,
9+
} from './counterSlice'
10+
11+
describe('counter reducer', () => {
12+
const initialState: CounterSliceState = {
13+
value: 3,
14+
status: 'idle',
15+
}
16+
17+
let store = makeStore()
18+
19+
beforeEach(() => {
20+
store = makeStore({ counter: initialState })
21+
})
22+
23+
it('should handle initial state', () => {
24+
expect(counterSlice.reducer(undefined, { type: 'unknown' })).toStrictEqual({
25+
value: 0,
26+
status: 'idle',
27+
})
28+
})
29+
30+
it('should handle increment', () => {
31+
expect(selectCount(store.getState())).toBe(3)
32+
33+
store.dispatch(increment())
34+
35+
expect(selectCount(store.getState())).toBe(4)
36+
})
37+
38+
it('should handle decrement', () => {
39+
expect(selectCount(store.getState())).toBe(3)
40+
41+
store.dispatch(decrement())
42+
43+
expect(selectCount(store.getState())).toBe(2)
44+
})
45+
46+
it('should handle incrementByAmount', () => {
47+
expect(selectCount(store.getState())).toBe(3)
48+
49+
store.dispatch(incrementByAmount(2))
50+
51+
expect(selectCount(store.getState())).toBe(5)
52+
})
53+
})
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,89 @@
1-
import type { PayloadAction } from '@reduxjs/toolkit';
2-
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
3-
import type { AppThunk } from '../../app/store';
4-
import { fetchCount } from './counterAPI';
1+
import type { PayloadAction } from '@reduxjs/toolkit'
2+
import { createAppSlice } from '../../app/createAppSlice'
3+
import type { AppThunk } from '../../app/store'
4+
import { fetchCount } from './counterAPI'
55

6-
export interface CounterState {
7-
value: number;
8-
status: 'idle' | 'loading' | 'failed';
6+
export type CounterSliceState = {
7+
value: number
8+
status: 'idle' | 'loading' | 'failed'
99
}
1010

11-
const initialState: CounterState = {
11+
const initialState: CounterSliceState = {
1212
value: 0,
1313
status: 'idle',
14-
};
15-
16-
// The function below is called a thunk and allows us to perform async logic. It
17-
// can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This
18-
// will call the thunk with the `dispatch` function as the first argument. Async
19-
// code can then be executed and other actions can be dispatched. Thunks are
20-
// typically used to make async requests.
21-
export const incrementAsync = createAsyncThunk(
22-
'counter/fetchCount',
23-
async (amount: number) => {
24-
const response = await fetchCount(amount);
25-
// The value we return becomes the `fulfilled` action payload
26-
return response.data;
27-
},
28-
);
14+
}
2915

30-
export const counterSlice = createSlice({
16+
// If you are not using async thunks you can use the standalone `createSlice`.
17+
export const counterSlice = createAppSlice({
3118
name: 'counter',
3219
// `createSlice` will infer the state type from the `initialState` argument
3320
initialState,
3421
// The `reducers` field lets us define reducers and generate associated actions
35-
reducers: {
36-
increment: state => {
22+
reducers: create => ({
23+
increment: create.reducer(state => {
3724
// Redux Toolkit allows us to write "mutating" logic in reducers. It
3825
// doesn't actually mutate the state because it uses the Immer library,
3926
// which detects changes to a "draft state" and produces a brand new
4027
// immutable state based off those changes
41-
state.value += 1;
42-
},
43-
decrement: state => {
44-
state.value -= 1;
45-
},
28+
state.value += 1
29+
}),
30+
decrement: create.reducer(state => {
31+
state.value -= 1
32+
}),
4633
// Use the `PayloadAction` type to declare the contents of `action.payload`
47-
incrementByAmount: (state, action: PayloadAction<number>) => {
48-
state.value += action.payload;
49-
},
50-
},
51-
52-
// The `extraReducers` field lets the slice handle actions defined elsewhere,
53-
// including actions generated by createAsyncThunk or in other slices.
54-
extraReducers: builder => {
55-
builder
56-
.addCase(incrementAsync.pending, state => {
57-
state.status = 'loading';
58-
})
59-
.addCase(incrementAsync.fulfilled, (state, action) => {
60-
state.status = 'idle';
61-
state.value += action.payload;
62-
})
63-
.addCase(incrementAsync.rejected, state => {
64-
state.status = 'failed';
65-
});
66-
},
67-
34+
incrementByAmount: create.reducer(
35+
(state, action: PayloadAction<number>) => {
36+
state.value += action.payload
37+
},
38+
),
39+
// The function below is called a thunk and allows us to perform async logic. It
40+
// can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This
41+
// will call the thunk with the `dispatch` function as the first argument. Async
42+
// code can then be executed and other actions can be dispatched. Thunks are
43+
// typically used to make async requests.
44+
incrementAsync: create.asyncThunk(
45+
async (amount: number) => {
46+
const response = await fetchCount(amount)
47+
// The value we return becomes the `fulfilled` action payload
48+
return response.data
49+
},
50+
{
51+
pending: state => {
52+
state.status = 'loading'
53+
},
54+
fulfilled: (state, action) => {
55+
state.status = 'idle'
56+
state.value += action.payload
57+
},
58+
rejected: state => {
59+
state.status = 'failed'
60+
},
61+
},
62+
),
63+
}),
64+
// You can define your selectors here. These selectors receive the slice
65+
// state as their first argument.
6866
selectors: {
6967
selectCount: counter => counter.value,
68+
selectStatus: counter => counter.status,
7069
},
71-
});
70+
})
7271

73-
// Action creators are generated for each case reducer function
74-
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
72+
// Action creators are generated for each case reducer function.
73+
export const { decrement, increment, incrementByAmount, incrementAsync } =
74+
counterSlice.actions
7575

76-
// Other code such as selectors can use the imported `RootState` type
77-
export const { selectCount } = counterSlice.selectors;
76+
// Selectors returned by `slice.selectors` take the root state as their first argument.
77+
export const { selectCount, selectStatus } = counterSlice.selectors
7878

7979
// We can also write thunks by hand, which may contain both sync and async logic.
8080
// Here's an example of conditionally dispatching actions based on current state.
8181
export const incrementIfOdd =
8282
(amount: number): AppThunk =>
8383
(dispatch, getState) => {
84-
const currentValue = selectCount(getState());
85-
if (currentValue % 2 === 1) {
86-
dispatch(incrementByAmount(amount));
84+
const currentValue = selectCount(getState())
85+
86+
if (currentValue % 2 === 1 || currentValue % 2 === -1) {
87+
dispatch(incrementByAmount(amount))
8788
}
88-
};
89+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import type { JSX } from 'react'
2+
import { useState } from 'react'
3+
import {
4+
Modal,
5+
ScrollView,
6+
StyleSheet,
7+
Text,
8+
TouchableOpacity,
9+
View,
10+
useColorScheme,
11+
} from 'react-native'
12+
import { Colors } from '../../constants/Colors'
13+
import { useGetQuotesQuery } from './quotesApiSlice'
14+
15+
const options = [5, 10, 20, 30]
16+
17+
export const Quotes = (): JSX.Element | null => {
18+
const isDarkMode = useColorScheme() === 'dark'
19+
const textStyle = {
20+
color: isDarkMode ? Colors.light : Colors.dark,
21+
}
22+
const backgroundStyle = {
23+
backgroundColor: isDarkMode ? Colors.darker : Colors.lighter,
24+
}
25+
26+
const [numberOfQuotes, setNumberOfQuotes] = useState(10)
27+
const [modalVisible, setModalVisible] = useState(false)
28+
// Using a query hook automatically fetches data and returns query values
29+
const { data, isError, isLoading, isSuccess } =
30+
useGetQuotesQuery(numberOfQuotes)
31+
32+
if (isError) {
33+
return <Text>There was an error!!!</Text>
34+
}
35+
36+
if (isLoading) {
37+
return <Text>Loading...</Text>
38+
}
39+
40+
const pickNumberOfQuotes = (value: number) => {
41+
setNumberOfQuotes(value)
42+
setModalVisible(false)
43+
}
44+
45+
if (isSuccess) {
46+
return (
47+
<View style={styles.container}>
48+
<TouchableOpacity
49+
onPress={() => {
50+
setModalVisible(true)
51+
}}
52+
style={styles.button}
53+
>
54+
<Text style={styles.buttonText}>
55+
Select the Quantity of Quotes to Fetch: {numberOfQuotes}
56+
</Text>
57+
</TouchableOpacity>
58+
59+
<Modal
60+
animationType="slide"
61+
transparent={true}
62+
visible={modalVisible}
63+
style={backgroundStyle}
64+
onRequestClose={() => {
65+
setModalVisible(false)
66+
}}
67+
>
68+
<View style={[styles.modalView, backgroundStyle]}>
69+
<ScrollView style={styles.quotesList}>
70+
{options.map(option => (
71+
<TouchableOpacity
72+
key={option}
73+
style={styles.option}
74+
onPress={() => {
75+
pickNumberOfQuotes(option)
76+
}}
77+
>
78+
<Text style={[styles.optionText, textStyle]}>{option}</Text>
79+
</TouchableOpacity>
80+
))}
81+
</ScrollView>
82+
</View>
83+
</Modal>
84+
85+
{
86+
<ScrollView>
87+
{data.quotes.map(({ author, quote, id }) => (
88+
<View key={id} style={[styles.quoteContainer, backgroundStyle]}>
89+
<Text
90+
style={[styles.quoteText, textStyle]}
91+
>{`"${quote}"`}</Text>
92+
<Text style={[styles.author, textStyle]}>- {author}</Text>
93+
</View>
94+
))}
95+
</ScrollView>
96+
}
97+
</View>
98+
)
99+
}
100+
101+
return null
102+
}
103+
104+
const styles = StyleSheet.create({
105+
container: {
106+
flex: 1,
107+
alignItems: 'center',
108+
justifyContent: 'center',
109+
padding: 20,
110+
},
111+
button: {
112+
padding: 10,
113+
backgroundColor: 'rgba(112, 76, 182, 0.1)',
114+
borderRadius: 5,
115+
},
116+
buttonText: {
117+
color: 'rgb(112, 76, 182)',
118+
fontSize: 18,
119+
textAlign: 'center',
120+
margin: 5,
121+
},
122+
modalView: {
123+
margin: 20,
124+
borderRadius: 5,
125+
padding: 20,
126+
alignItems: 'center',
127+
elevation: 5,
128+
},
129+
option: {
130+
fontSize: 30,
131+
padding: 10,
132+
borderBottomWidth: 1,
133+
borderBottomColor: '#CCC',
134+
},
135+
optionText: {
136+
fontSize: 20,
137+
},
138+
quotesList: {
139+
width: 'auto',
140+
},
141+
quoteContainer: {
142+
padding: 10,
143+
borderRadius: 5,
144+
marginVertical: 5,
145+
},
146+
quoteText: {
147+
fontStyle: 'italic',
148+
},
149+
author: {
150+
fontWeight: 'bold',
151+
textAlign: 'right',
152+
marginTop: 5,
153+
},
154+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Need to use the React-specific entry point to import `createApi`
2+
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
3+
4+
type Quote = {
5+
id: number
6+
quote: string
7+
author: string
8+
}
9+
10+
type QuotesApiResponse = {
11+
quotes: Quote[]
12+
total: number
13+
skip: number
14+
limit: number
15+
}
16+
17+
// Define a service using a base URL and expected endpoints
18+
export const quotesApiSlice = createApi({
19+
baseQuery: fetchBaseQuery({ baseUrl: 'https://dummyjson.com/quotes' }),
20+
reducerPath: 'quotesApi',
21+
// Tag types are used for caching and invalidation.
22+
tagTypes: ['Quotes'],
23+
endpoints: build => ({
24+
// Supply generics for the return type (in this case `QuotesApiResponse`)
25+
// and the expected query argument. If there is no argument, use `void`
26+
// for the argument type instead.
27+
getQuotes: build.query<QuotesApiResponse, number>({
28+
query: (limit = 10) => `?limit=${limit.toString()}`,
29+
// `providesTags` determines which 'tag' is attached to the
30+
// cached data returned by the query.
31+
providesTags: (_result, _error, id) => [{ type: 'Quotes', id }],
32+
}),
33+
}),
34+
})
35+
36+
// Hooks are auto-generated by RTK-Query
37+
// Same as `quotesApiSlice.endpoints.getQuotes.useQuery`
38+
export const { useGetQuotesQuery } = quotesApiSlice
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { RenderOptions } from '@testing-library/react-native'
2+
import { render, userEvent } from '@testing-library/react-native'
3+
import type { PropsWithChildren, ReactElement } from 'react'
4+
import { Provider } from 'react-redux'
5+
import type { AppStore, RootState } from '../app/store'
6+
import { makeStore } from '../app/store'
7+
8+
/**
9+
* This type extends the default options for
10+
* React Testing Library's render function. It allows for
11+
* additional configuration such as specifying an initial Redux state and
12+
* a custom store instance.
13+
*/
14+
type ExtendedRenderOptions = Omit<RenderOptions, 'queries'> & {
15+
/**
16+
* Defines a specific portion or the entire initial state for the Redux store.
17+
* This is particularly useful for initializing the state in a
18+
* controlled manner during testing, allowing components to be rendered
19+
* with predetermined state conditions.
20+
*/
21+
preloadedState?: Partial<RootState>
22+
23+
/**
24+
* Allows the use of a specific Redux store instance instead of a
25+
* default or global store. This flexibility is beneficial when
26+
* testing components with unique store requirements or when isolating
27+
* tests from a global store state. The custom store should be configured
28+
* to match the structure and middleware of the store used by the application.
29+
*
30+
* @default makeStore(preloadedState)
31+
*/
32+
store?: AppStore
33+
}
34+
35+
/**
36+
* Renders the given React element with Redux Provider and custom store.
37+
* This function is useful for testing components that are connected to the Redux store.
38+
*
39+
* @param ui - The React component or element to render.
40+
* @param extendedRenderOptions - Optional configuration options for rendering. This includes `preloadedState` for initial Redux state and `store` for a specific Redux store instance. Any additional properties are passed to React Testing Library's render function.
41+
* @returns An object containing the Redux store used in the render, User event API for simulating user interactions in tests, and all of React Testing Library's query functions for testing the component.
42+
*/
43+
export const renderWithProviders = (
44+
ui: ReactElement,
45+
extendedRenderOptions: ExtendedRenderOptions = {},
46+
) => {
47+
const {
48+
preloadedState = {},
49+
// Automatically create a store instance if no store was passed in
50+
store = makeStore(preloadedState),
51+
...renderOptions
52+
} = extendedRenderOptions
53+
54+
const Wrapper = ({ children }: PropsWithChildren) => (
55+
<Provider store={store}>{children}</Provider>
56+
)
57+
58+
// Return an object with the store and all of RTL's query functions
59+
return {
60+
store,
61+
user: userEvent.setup(),
62+
...render(ui, { wrapper: Wrapper, ...renderOptions }),
63+
}
64+
}

‎examples/publish-ci/expo/yarn.lock

+1,921-2,947
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.