├── .vscode └── settings.json ├── .gitattributes ├── src ├── index.ts ├── useStyle.ts ├── useFlatStyle.ts └── findStyle.ts ├── .prettierrc.js ├── tsconfig.json ├── LICENSE ├── .gitignore ├── package.json ├── README.md └── .eslintrc.js /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./findStyle"; 2 | export * from "./useStyle"; 3 | export * from "./useFlatStyle"; 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: true, 3 | jsxBracketSameLine: true, 4 | singleQuote: false, 5 | printWidth: 80, 6 | tabWidth: 2, 7 | semi: true, 8 | trailingComma: "es5", 9 | endOfLine: "lf", 10 | arrowParens: "always", 11 | }; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "compilerOptions": { 4 | "target": "ES2019", 5 | "module": "commonjs", 6 | "outDir": "dist", 7 | "declaration": true, 8 | "allowJs": true, 9 | "allowSyntheticDefaultImports": true, 10 | "esModuleInterop": true, 11 | "isolatedModules": true, 12 | "jsx": "react-native", 13 | "lib": ["ES2019"], 14 | "moduleResolution": "node", 15 | "noEmit": true, 16 | "strict": true, 17 | "skipLibCheck": true, 18 | "removeComments": true, 19 | "resolveJsonModule": true, 20 | "noUncheckedIndexedAccess": true, 21 | "incremental": true, 22 | "pretty": true, 23 | }, 24 | "exclude": [ 25 | "node_modules", 26 | "@types", 27 | "dist", 28 | ".eslintrc.js", 29 | ".prettierrc.js" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Marc Rousavy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/useStyle.ts: -------------------------------------------------------------------------------- 1 | import { DependencyList, useMemo } from "react"; 2 | import { ImageStyle, StyleProp, TextStyle, ViewStyle } from "react-native"; 3 | 4 | /** 5 | * A hook to memoize a style. Uses `ViewStyle` per default, but can be used with other styles deriving from `FlexStyle` as well, such as `TextStyle`. 6 | * @param styleFactory The function that returns a style 7 | * @param deps The dependencies to trigger memoization re-evaluation 8 | * @see ["Memoize!!! 💾 - a react (native) performance guide"](https://gist.github.com/mrousavy/0de7486814c655de8a110df5cef74ddc) 9 | * @example 10 | * 11 | * // simple object styles 12 | * const style1 = useStyle(() => ({ height: someDynamicValue }), [someDynamicValue]) 13 | * 14 | * // array styles 15 | * const style2 = useStyle( 16 | * () => [styles.container, props.style, { height: someDynamicValue }], 17 | * [props.style, someDynamicValue] 18 | * ); 19 | */ 20 | export const useStyle = < 21 | TStyle extends ViewStyle | TextStyle | ImageStyle, 22 | TOutput extends StyleProp 23 | >( 24 | styleFactory: () => TOutput, 25 | deps?: DependencyList 26 | ): TOutput => 27 | // eslint-disable-next-line react-hooks/exhaustive-deps 28 | useMemo(styleFactory, deps); 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # FuseBox cache 76 | .fusebox/ 77 | 78 | # TypeScript output 79 | dist 80 | -------------------------------------------------------------------------------- /src/useFlatStyle.ts: -------------------------------------------------------------------------------- 1 | import { DependencyList, useMemo } from "react"; 2 | import { 3 | ImageStyle, 4 | StyleProp, 5 | StyleSheet, 6 | TextStyle, 7 | ViewStyle, 8 | } from "react-native"; 9 | 10 | /** 11 | * A hook to memoize a style and flatten it into a single object. Uses `ViewStyle` per default, but can be used with other styles deriving from `FlexStyle` as well, such as `TextStyle`. 12 | * @param styleFactory The function that returns a style 13 | * @param deps The dependencies to trigger memoization re-evaluation 14 | * @see ["Memoize!!! 💾 - a react (native) performance guide"](https://gist.github.com/mrousavy/0de7486814c655de8a110df5cef74ddc) 15 | * @example 16 | * 17 | * // simple object styles, same as with `useStyle` 18 | * const style1 = useFlatStyle(() => ({ height: someDynamicValue }), [someDynamicValue]) 19 | * 20 | * // array styles get flattened into a single object 21 | * const style2 = useFlatStyle( 22 | * () => [styles.container, props.style, { height: someDynamicValue }], 23 | * [props.style, someDynamicValue] 24 | * ); 25 | * Array.isArray(style2); // => false 26 | */ 27 | export const useFlatStyle = < 28 | TStyle extends ViewStyle | TextStyle | ImageStyle, 29 | TOutput extends StyleProp 30 | >( 31 | styleFactory: () => TOutput, 32 | deps?: DependencyList 33 | ): TStyle extends (infer U)[] ? U : TStyle => 34 | // eslint-disable-next-line react-hooks/exhaustive-deps 35 | useMemo(() => StyleSheet.flatten(styleFactory()), deps); 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-style-utilities", 3 | "version": "1.0.1", 4 | "description": "Fully typed hooks and utility functions for the React Native StyleSheet API", 5 | "main": "dist/index", 6 | "types": "dist/index.d.ts", 7 | "react-native": "src/index", 8 | "source": "src/index", 9 | "scripts": { 10 | "prepack": "tsc --noEmit false" 11 | }, 12 | "files": [ 13 | "src", 14 | "dist" 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/mrousavy/react-native-style-utilities.git" 19 | }, 20 | "keywords": [ 21 | "react", 22 | "native", 23 | "style", 24 | "utils", 25 | "utilities", 26 | "useStyle", 27 | "useFlatStyle", 28 | "findStyle", 29 | "registered", 30 | "style", 31 | "library" 32 | ], 33 | "author": "Marc Rousavy", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/mrousavy/react-native-style-utilities/issues" 37 | }, 38 | "homepage": "https://github.com/mrousavy/react-native-style-utilities#readme", 39 | "peerDependencies": { 40 | "react": "*", 41 | "react-native": "*" 42 | }, 43 | "devDependencies": { 44 | "@react-native-community/eslint-config": "^2.0.0", 45 | "@react-native-community/eslint-plugin": "^1.1.0", 46 | "@testing-library/react-hooks": "^5.0.3", 47 | "@types/react": "^17.0.0", 48 | "@types/react-native": "^0.63.46", 49 | "@typescript-eslint/eslint-plugin": "^4.22.0", 50 | "@typescript-eslint/parser": "^4.22.0", 51 | "eslint": "^7.24.0", 52 | "eslint-config-prettier": "^7.2.0", 53 | "eslint-plugin-prettier": "^3.4.0", 54 | "eslint-plugin-react": "^7.22.0", 55 | "eslint-plugin-react-hooks": "^4.2.0", 56 | "eslint-plugin-react-native": "^3.10.0", 57 | "jest": "^26.6.3", 58 | "prettier": "^2.2.1", 59 | "react-native": "^0.63.4", 60 | "react-test-renderer": "^17.0.1", 61 | "ts-jest": "^26.5.1", 62 | "typescript": "^4.1.3" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/findStyle.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ImageStyle, 3 | RegisteredStyle, 4 | StyleProp, 5 | TextStyle, 6 | ViewStyle, 7 | } from "react-native"; 8 | 9 | const isRegisteredStyle = ( 10 | style: T | unknown 11 | ): style is RegisteredStyle => { 12 | if (typeof style === "object" && style != null) 13 | return "__registeredStyleBrand" in style; 14 | else return false; 15 | }; 16 | 17 | /** 18 | * Find a specific value in the given style 19 | * @param style The style to search the given key in 20 | * @param stylePropertyKey The style property to search for 21 | * @returns The value of the found style property, or `undefined` if not found 22 | * @example 23 | * 24 | * function Component({ style, ...props }) { 25 | * const borderRadius = useMemo(() => findStyle(style, "borderRadius"), [style]) 26 | * // borderRadius is 'number' if the style (array, object, ...) has a borderRadius set somewhere, 27 | * // 'undefined' if not. 28 | * 29 | * // change logic to apply borderRadius somewhere, e.g. use `overflow: 'hidden'` on children 30 | * } 31 | */ 32 | export const findStyle = < 33 | TStyle extends ViewStyle | TextStyle | ImageStyle, 34 | TResult extends TStyle extends (infer U)[] ? U : TStyle, 35 | TName extends keyof TResult 36 | >( 37 | style: StyleProp, 38 | stylePropertyKey: TName 39 | ): TResult[TName] | undefined => { 40 | if (Array.isArray(style)) { 41 | // we're doing a reverse loop because values in elements at the end override values at the beginning 42 | for (let i = style.length - 1; i >= 0; i--) { 43 | const result = findStyle( 44 | // @ts-expect-error it's complaining because it is `readonly`, but we're not modifying it anyways. StyleProp::RecursiveArray needs to be readonly. 45 | style[i], 46 | stylePropertyKey 47 | ); 48 | if (result != null) return result; 49 | } 50 | // style not found in array 51 | return undefined; 52 | } else { 53 | if (style == null) { 54 | // null, undefined 55 | return undefined; 56 | } else if (typeof style === "boolean") { 57 | // false 58 | return undefined; 59 | } else if (isRegisteredStyle(style)) { 60 | // RegisteredStyle (number) - does not actually exist. 61 | // @ts-expect-error typings for StyleProp<> are really hard 62 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 63 | return style.__registeredStyleBrand[stylePropertyKey]; 64 | } else if (typeof style === "object") { 65 | // { ... } 66 | // @ts-expect-error typings for StyleProp<> are really hard 67 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 68 | return style[stylePropertyKey]; 69 | } else { 70 | // it's not a known style type. 71 | return undefined; 72 | } 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

react-native-style-utilities

3 |

Fully typed hooks and utility functions for the React Native StyleSheet API

4 |
5 |
npm i react-native-style-utilities
6 |
7 |
8 | 9 | ## ESLint Setup 10 | 11 | If you're using the [eslint-plugin-react-hooks](https://www.npmjs.com/package/eslint-plugin-react-hooks) plugin, add the following to your `.eslintrc.js`: 12 | 13 | ```js 14 | "react-hooks/exhaustive-deps": [ 15 | "error", 16 | { 17 | additionalHooks: "(useStyle|useFlatStyle)", 18 | }, 19 | ], 20 | ``` 21 | 22 |
23 |
24 | 25 | ## `useStyle` 26 | 27 | A hook to memoize dynamic styles. 28 | 29 | > See ["Memoize!!! 💾 - a react (native) performance guide"](https://gist.github.com/mrousavy/0de7486814c655de8a110df5cef74ddc) 30 | 31 | ### Objects 32 | 33 | By using `useStyle` the object `{ height: number }` gets memoized and will only be re-created if `someDynamicValue` changes, resulting in **better optimized re-renders**. 34 | 35 | #### Bad 36 | 37 | ```tsx 38 | return 39 | ``` 40 | 41 | #### Good 42 | 43 | ```tsx 44 | const style = useStyle(() => ({ height: someDynamicValue }), [someDynamicValue]) 45 | 46 | return 47 | ``` 48 | 49 | ### Arrays 50 | 51 | `useStyle` can also be used to join arrays together, also improving re-render times. 52 | 53 | #### Bad 54 | 55 | ```tsx 56 | return 57 | ``` 58 | 59 | #### Good 60 | 61 | ```tsx 62 | const style = useStyle( 63 | () => [styles.container, props.style, { height: someDynamicValue }], 64 | [props.style, someDynamicValue] 65 | ); 66 | 67 | return 68 | ``` 69 | 70 |
71 |
72 | 73 | ## `useFlatStyle` 74 | 75 | Same as `useStyle`, but flattens ("merges") the returned values into a simple object with [`StyleSheet.flatten(...)`](https://reactnative.dev/docs/stylesheet#flatten). 76 | 77 | > See ["Memoize!!! 💾 - a react (native) performance guide"](https://gist.github.com/mrousavy/0de7486814c655de8a110df5cef74ddc) 78 | 79 | ```tsx 80 | const style1 = useStyle( 81 | () => [styles.container, props.style, { height: someDynamicValue }], 82 | [props.style, someDynamicValue] 83 | ); 84 | style1.borderRadius // <-- does not work, `style1` is an array! 85 | 86 | const style2 = useFlatStyle( 87 | () => [styles.container, props.style, { height: someDynamicValue }], 88 | [props.style, someDynamicValue] 89 | ); 90 | style2.borderRadius // <-- works, will return 'number | undefined' 91 | ``` 92 | 93 |
94 |
95 | 96 | ## `findStyle` 97 | 98 | A helper function to find a given style property in any style object without using expensive flattening (no `StyleSheet.flatten(...)`). 99 | 100 | ```tsx 101 | function Component({ style, ...props }) { 102 | const borderRadius = style.borderRadius // <-- does not work, style type is complex 103 | const borderRadius = findStyle(style, "borderRadius") // <-- works, is 'number | undefined' 104 | } 105 | ``` 106 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globals: { 3 | Logger: true, 4 | }, 5 | parserOptions: { 6 | tsconfigRootDir: __dirname, 7 | project: ["./tsconfig.json"], 8 | ecmaFeatures: { 9 | jsx: true 10 | } 11 | }, 12 | root: true, 13 | env: { 14 | "react-native/react-native": true 15 | }, 16 | parser: "@typescript-eslint/parser", 17 | plugins: [ 18 | "@typescript-eslint", 19 | "prettier", 20 | "react", 21 | "react-native", 22 | ], 23 | extends: [ 24 | "@react-native-community", 25 | "eslint:recommended", 26 | "plugin:@typescript-eslint/recommended", 27 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 28 | "prettier/@typescript-eslint", 29 | "plugin:prettier/recommended", 30 | ], 31 | rules: { 32 | // regular rules 33 | "no-catch-shadow": "off", 34 | "arrow-body-style": "warn", 35 | "no-return-await": "warn", 36 | // "prefer-arrow-callback": "error", //not sure about this one, might be buggy 37 | "func-style": ["error", "expression"], 38 | "eslint-comments/no-unused-disable": "error", 39 | "no-console": "error", 40 | "radix": "error", 41 | "spaced-comment": [ 42 | "error", 43 | "always", 44 | { 45 | block: { balanced: true }, 46 | // allow repeating * instead of a whole comment 47 | exceptions: ["*"], 48 | // allow these markers to appear before the required space 49 | markers: ["#region", "#endregion", "+", "-", "*"], 50 | }, 51 | ], 52 | "no-implicit-coercion": "error", 53 | "no-nested-ternary": "warn", 54 | "operator-linebreak": [ 55 | "warn", 56 | "after", 57 | // must be ignored since it conflicts with prettier 58 | { overrides: { "?": "ignore", ":": "ignore" } }, 59 | ], 60 | "eqeqeq": ["error", "always", { null: "never" }], 61 | 62 | // dumb rules that are off so the much smarter typescript-eslint can use them 63 | "no-shadow": "off", 64 | "no-unused-vars": "off", 65 | "require-await": "off", 66 | 67 | // react-native rules 68 | "react-native/no-unused-styles": "error", 69 | "react-native/no-inline-styles": "error", 70 | "react-native/no-single-element-style-arrays": "error", 71 | // "react-native/no-raw-text": "error", // doesn't work since we use "" 72 | // react-native/no-color-literals: "warn", //also do this at some point 73 | 74 | // not type-aware rules 75 | "@typescript-eslint/no-shadow": "warn", 76 | "@typescript-eslint/no-unused-vars": [ 77 | "error", 78 | { 79 | vars: "all", 80 | args: "after-used", 81 | ignoreRestSiblings: false, 82 | varsIgnorePattern: "^_", 83 | argsIgnorePattern: "^_", 84 | }, 85 | ], 86 | "@typescript-eslint/no-use-before-define": [ 87 | "error", 88 | { 89 | functions: true, 90 | classes: true, 91 | variables: false, 92 | enums: true, 93 | typedefs: true, 94 | }, 95 | ], 96 | "@typescript-eslint/no-var-requires": "off", 97 | "@typescript-eslint/explicit-function-return-type": "off", 98 | "@typescript-eslint/array-type": "error", 99 | "@typescript-eslint/member-delimiter-style": "error", 100 | "@typescript-eslint/method-signature-style": "error", 101 | "@typescript-eslint/prefer-for-of": "error", 102 | "@typescript-eslint/prefer-optional-chain": "error", 103 | "@typescript-eslint/prefer-ts-expect-error": "error", 104 | "@typescript-eslint/class-literal-property-style": "error", 105 | "@typescript-eslint/unified-signatures": "error", 106 | "@typescript-eslint/no-invalid-void-type": "error", 107 | "@typescript-eslint/no-extraneous-class": "error", 108 | "@typescript-eslint/no-floating-promises": "off", 109 | "@typescript-eslint/require-await": "warn", // changes default severity 110 | "@typescript-eslint/no-base-to-string": "error", 111 | "@typescript-eslint/no-throw-literal": "error", 112 | "@typescript-eslint/no-unnecessary-boolean-literal-compare": "error", 113 | "@typescript-eslint/no-unnecessary-qualifier": "error", 114 | "@typescript-eslint/no-unnecessary-type-arguments": "warn", 115 | "@typescript-eslint/prefer-includes": "error", 116 | "@typescript-eslint/prefer-nullish-coalescing": "warn", 117 | "@typescript-eslint/prefer-readonly": "warn", 118 | "@typescript-eslint/prefer-reduce-type-parameter": "warn", 119 | "@typescript-eslint/prefer-string-starts-ends-with": "error", 120 | "@typescript-eslint/promise-function-async": "error", 121 | "@typescript-eslint/require-array-sort-compare": "warn", 122 | "@typescript-eslint/switch-exhaustiveness-check": "warn", 123 | "@typescript-eslint/unbound-method": "warn", 124 | "@typescript-eslint/strict-boolean-expressions": [ 125 | "error", 126 | { 127 | allowString: false, 128 | allowNullableObject: false, 129 | allowNumber: false, 130 | allowNullableBoolean: true, 131 | }, 132 | ], 133 | "@typescript-eslint/no-non-null-assertion": "error", 134 | "@typescript-eslint/no-unnecessary-condition": "error", 135 | 136 | // "any" rules that _should_ be enabled but are too much work for us rn 137 | "@typescript-eslint/no-unsafe-member-access": "warn", 138 | "@typescript-eslint/restrict-template-expressions": [ 139 | "warn", 140 | { 141 | allowNumber: true, 142 | allowBoolean: true, 143 | allowNullish: true, 144 | } 145 | ], 146 | 147 | // react hooks 148 | "react-hooks/exhaustive-deps": [ 149 | "error", 150 | { 151 | additionalHooks: "(useStyle|useFlatStyle)", 152 | }, 153 | ], 154 | 155 | // Prettier 156 | "prettier/prettier": "warn", 157 | }, 158 | }; 159 | --------------------------------------------------------------------------------