Is there a way to share (React Native) components, services, hooks etc. between 2 Expo projects?
Ideally all the common functionality would be in a 3rd project and both of these projects would reference it.
Is this possible without having to publish/upload the common project to npm, GitHub, etc.?
This question is regarding blank project that are created with create-expo-app
and don't contain metro.config
or babel.config
.
# Create expo react typescript project
npx create-expo-app --template expo-template-blank-typescript
# Install react dom
npm install --save react-dom@19.0.0
# Install web component for react native
npm i --save react-native-web@^0.20.0 --force
# Install metro
npm i --save-dev @react-native/metro-config
npm i --save-dev @expo/metro-runtime@~5.0.4
Resulting project structure:
├─assets/
├─.gitignore
├─App.tsx
├─App.json
├─index.ts
├─package-lock.json
├─package.json
└─tsconfig.json
This is a "working" solution for the current version of metro, that is otherwise having trouble reading sibling projects.
If you're able to publish to npm and install the second project as a normal node module you are strongly encouraged to do that instead.
This metro resolver override only works for typescript projects.
tsconfig.json
to something like:{
"compilerOptions": {
"jsx": "react-jsx",
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"noEmit": false,
"composite": true,
"declarationMap": true,
"moduleResolution": "node16",
"module": "node16"
}
}
Create an index.ts
file in the root or the /src
directory (of the common project).
That exports all the sub modules.
For example:
export * from "./components";
export * from "./hooks";
export * from "./services";
Edit the package.json
in the common project to point to the main index you just created
"main": "index.ts",
or
"main": "src/index.ts",
Install the metro dependencies on your main project
npm install --save-dev metro-react-native-babel-preset
npm install --save-dev metro-resolver
Install the sibling project using node on your main project
npm install "../<SiblingDirectoryName>"
metro.config.js
file in your root directoryDon't forget to replace the respective directory names (<!CommonDirectoryNameHERE!>
, <!CurrentDirectoryNameHERE!>
, <!CommonProjectNameHere!>
)
const path = require("path");
const fs = require("fs");
const { getDefaultConfig, mergeConfig } = require("@react-native/metro-config");
const { resolve } = require("metro-resolver");
const commonDirectoryName = "<!CommonDirectoryNameHERE!>";
const currentDirectoryName = "<!CurrentDirectoryNameHERE!>";
// these could also be derived by reading package.json and the current directory info
const defaultConfig = getDefaultConfig(__dirname);
/** @type {typeof defaultConfig} */
const config = {
transformer: {
unstable_allowRequireContext: true,
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
}),
},
watchFolders: [
__dirname,
path.resolve(__dirname, path.join("..", commonDirectoryName)),
],
resolver: {
alias: {
"<!CommonProjectNameHere!>": path.resolve(__dirname, path.join("..", commonDirectoryName, "src")),
},
resolveRequest: (context, moduleName, platform) => {
const isCommonProjectReference = moduleName.includes(commonDirectoryName);
const isFromCommonProjectReference = context.originModulePath.includes(commonDirectoryName);
const isFromNodeModule = context.originModulePath.includes("node_modules");
const isRelativePath = /(^\.\/)|(^\.\.\/)/.test(moduleName);
const isLibraryModule = isFromNodeModule || !isRelativePath;
const isCommonModule = isCommonProjectReference || (isFromCommonProjectReference && !isLibraryModule);
if (isCommonModule) {
const modulePath = path.resolve(context.originModulePath, path.join("..", moduleName));
const filePath = getModuleFilePath(modulePath);
return {
type: "sourceFile",
filePath: filePath,
};
} else {
if (isFromCommonProjectReference && isLibraryModule) {
const modulePath = context.originModulePath;
const replacedPath = modulePath.replace(commonDirectoryName, currentDirectoryName);
const redirectedPath = context.redirectModulePath(replacedPath);
if (redirectedPath !== false) context.originModulePath = redirectedPath;
else throw Error(`Failed to redirect module ${moduleName} (${modulePath})`);
}
return resolve(context, moduleName, platform);
}
},
},
};
module.exports = mergeConfig(defaultConfig, config);
/**
* @param {string} modulePath
*/
function getModuleFilePath(modulePath) {
const { dir: filePath, name: fileName } = path.parse(modulePath);
const parentDirectoryFiles = fs.readdirSync(filePath);
const matchingParentDirectoryFiles = parentDirectoryFiles.filter(name => name.replace(/\..+$/, "") === fileName);
const hasJavaScriptFiles = matchingParentDirectoryFiles.some(name => /\.js(x?)$/.test(name));
const matchingTypescriptFiles = matchingParentDirectoryFiles.filter(name => /(\.d)?\.ts(x?)$/.test(name));
if (hasJavaScriptFiles) {
throw Error(`The metro bundler can't read the javascript files for "${modulePath}"`);
} else if (matchingTypescriptFiles.length > 1) {
throw Error(`The metro bundler found too many source files for "${modulePath}"`);
} else if (matchingTypescriptFiles.length === 1) {
const [matchedFile] = matchingTypescriptFiles;
return path.join(filePath, matchedFile);
} else {
return path.join(modulePath, "index.ts");
}
}
Follow this step if you want to be able to have this structure
import { Hook1, Hook2 } from "common-project/hooks";
import { Service1 } from "common-project/services";
over
import { Hook1, Hook2, Service1 } from "common-project";
Edit your package.json
to include all your exported sub-modules:
"exports": {
".": {
"import": "./src/index.ts"
},
"./components": {
"import": "./src/components/index.ts"
},
"./services": {
"import": "./src/services/index.ts"
},
"./hooks": {
"import": "./src/hooks/index.ts"
}
},
expo-router
If you're using expo-router
with TypeScript you should also follow these steps:
npm install --save-dev @types/webpack-env
ExpoRoot
in your index.tsx
import { registerRootComponent } from "expo";
import { ExpoRoot, RequireContext } from "expo-router";
export default function App() {
const appContext = require.context("./app") as RequireContext;
// or ".src/app" depending on your structure
return <ExpoRoot context={appContext} />;
}
registerRootComponent(App);
package.json
to your index.tsx
"main": "index.tsx",
or
"main": "src/index.tsx",